From b1611dc2403715c576dfc454c5b40e8dbbbd8fb3 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Mon, 8 Jun 2026 00:57:36 +0530
Subject: [PATCH 01/30] Adding support of Secret Manager framework support in
Apache OFBiz. For various providers support we can add plugins components.
---
build.gradle | 7 +++
dependencies.gradle | 1 +
.../base/secret/FileBasedSecretProvider.java | 43 +++++++++++++
.../ofbiz/base/secret/SecretProvider.java | 49 +++++++++++++++
.../base/secret/SecretProviderFactory.java | 63 +++++++++++++++++++
.../entity/config/model/EntityConfig.java | 13 ++--
.../connection/DBCPConnectionFactory.java | 5 ++
7 files changed, 175 insertions(+), 6 deletions(-)
create mode 100644 framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
create mode 100644 framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
create mode 100644 framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
diff --git a/build.gradle b/build.gradle
index 10b13996637..73537e4980c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -169,6 +169,13 @@ tasks.withType(JavaCompile) {
// Enables Zip larger than 4 GB and more than 65535 entries
tasks.withType(Zip) { zip64 = true }
+// Multiple SecretProvider plugins each contribute the same META-INF/services/ filename.
+// Only one provider should be enabled at a time (via ofbiz-component.xml enabled="true/false").
+// FIRST keeps the alphabetically first file during transitional states where two are temporarily enabled.
+processResources {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
// Only used for release branches
def getCurrentGitBranch() {
return "git branch --show-current".execute().text.trim()
diff --git a/dependencies.gradle b/dependencies.gradle
index b890c4dac12..55507fd6020 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -100,6 +100,7 @@ dependencies {
runtimeOnly 'org.apache.axis2:axis2-transport-http:1.8.2'
runtimeOnly 'org.apache.axis2:axis2-transport-local:1.8.2'
runtimeOnly 'com.h2database:h2:2.4.240'
+ runtimeOnly 'com.mysql:mysql-connector-j:8.4.0'
runtimeOnly 'org.apache.geronimo.specs:geronimo-jaxrpc_1.1_spec:2.1'
runtimeOnly 'org.apache.logging.log4j:log4j-1.2-api:2.25.4' // for external jars using the old log4j1.2: routes logging to log4j 2
runtimeOnly 'org.apache.logging.log4j:log4j-jul:2.25.4' // for external jars using the java.util.logging: routes logging to log4j 2
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
new file mode 100644
index 00000000000..2879830d5e9
--- /dev/null
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
@@ -0,0 +1,43 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.base.secret;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
+import org.apache.ofbiz.base.util.UtilValidate;
+
+/**
+ * Default {@link SecretProvider} implementation that resolves secrets from
+ * {@code framework/base/config/passwords.properties}.
+ *
+ *
This preserves full backward compatibility with the existing
+ * {@code jdbc-password-lookup} mechanism in {@code entityengine.xml}.
+ * No configuration is required to use this implementation.
+ */
+public final class FileBasedSecretProvider implements SecretProvider {
+
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ String value = UtilProperties.getPropertyValue("passwords", key);
+ if (UtilValidate.isEmpty(value)) {
+ throw new GeneralException("Secret key '" + key + "' not found in passwords.properties");
+ }
+ return value;
+ }
+}
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
new file mode 100644
index 00000000000..11b1f868510
--- /dev/null
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.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.base.secret;
+
+import org.apache.ofbiz.base.util.GeneralException;
+
+/**
+ * SPI for resolving secrets and credentials (e.g. database passwords, API keys).
+ *
+ *
Implementations must be thread-safe. Register a custom implementation via
+ * Java's {@link java.util.ServiceLoader} by providing a file named
+ * {@code META-INF/services/org.apache.ofbiz.base.secret.SecretProvider} in
+ * your plugin JAR containing the fully-qualified class name of your
+ * implementation. If no custom implementation is registered,
+ * {@link FileBasedSecretProvider} is used as the default, which resolves
+ * secrets from {@code framework/base/config/passwords.properties}.
+ *
+ *
Example entry in a vault plugin's service descriptor:
+ */
+public interface SecretProvider {
+
+ /**
+ * Returns the secret value for the given key.
+ *
+ * @param key the identifier for the secret (e.g. {@code "jdbc-password.mydb"})
+ * @return the resolved secret value, never {@code null} or empty
+ * @throws GeneralException if the secret cannot be found or an error occurs during resolution
+ */
+ String getSecret(String key) throws GeneralException;
+}
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
new file mode 100644
index 00000000000..9d8ec088acd
--- /dev/null
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
@@ -0,0 +1,63 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.base.secret;
+
+import java.util.Iterator;
+import java.util.ServiceLoader;
+
+import org.apache.ofbiz.base.lang.ThreadSafe;
+import org.apache.ofbiz.base.util.Debug;
+
+/**
+ * Factory that provides the active {@link SecretProvider} instance.
+ *
+ *
On first access the factory uses Java's {@link ServiceLoader} to discover
+ * a custom {@link SecretProvider} registered in any plugin's
+ * {@code META-INF/services/org.apache.ofbiz.base.secret.SecretProvider} file.
+ * If none is found, {@link FileBasedSecretProvider} is used automatically,
+ * preserving backward compatibility with {@code passwords.properties}.
+ *
+ *
This mirrors the pattern already used by
+ * {@link org.apache.ofbiz.security.SecurityFactory}.
+ */
+@ThreadSafe
+public final class SecretProviderFactory {
+
+ private static final String MODULE = SecretProviderFactory.class.getName();
+
+ private static final SecretProvider INSTANCE = loadProvider();
+
+ private static SecretProvider loadProvider() {
+ Iterator it = ServiceLoader.load(SecretProvider.class).iterator();
+ if (it.hasNext()) {
+ SecretProvider provider = it.next();
+ Debug.logInfo("SecretProvider: using custom implementation " + provider.getClass().getName(), MODULE);
+ return provider;
+ }
+ Debug.logInfo("SecretProvider: no custom implementation found, using FileBasedSecretProvider", MODULE);
+ return new FileBasedSecretProvider();
+ }
+
+ /** Returns the active {@link SecretProvider} instance. */
+ public static SecretProvider getInstance() {
+ return INSTANCE;
+ }
+
+ private SecretProviderFactory() { }
+}
diff --git a/framework/entity/src/main/java/org/apache/ofbiz/entity/config/model/EntityConfig.java b/framework/entity/src/main/java/org/apache/ofbiz/entity/config/model/EntityConfig.java
index 1861e176cf1..ff65892e7ab 100644
--- a/framework/entity/src/main/java/org/apache/ofbiz/entity/config/model/EntityConfig.java
+++ b/framework/entity/src/main/java/org/apache/ofbiz/entity/config/model/EntityConfig.java
@@ -26,8 +26,9 @@
import java.util.Map;
import org.apache.ofbiz.base.lang.ThreadSafe;
+import org.apache.ofbiz.base.secret.SecretProviderFactory;
import org.apache.ofbiz.base.util.Debug;
-import org.apache.ofbiz.base.util.UtilProperties;
+import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilURL;
import org.apache.ofbiz.base.util.UtilXml;
import org.apache.ofbiz.entity.GenericEntityConfException;
@@ -353,12 +354,12 @@ public static String getJdbcPassword(InlineJdbc inlineJdbcElement) throws Generi
+ inlineJdbcElement.getLineNumber());
}
String key = "jdbc-password.".concat(jdbcPasswordLookup);
- jdbcPassword = UtilProperties.getPropertyValue("passwords", key);
- if (jdbcPassword.isEmpty()) {
- throw new GenericEntityConfException("'" + key + "' property not found in passwords.properties file for inline-jdbc element, line: "
- + inlineJdbcElement.getLineNumber());
+ try {
+ return SecretProviderFactory.getInstance().getSecret(key);
+ } catch (GeneralException e) {
+ throw new GenericEntityConfException("Secret not found for key '" + key + "' for inline-jdbc element, line: "
+ + inlineJdbcElement.getLineNumber() + " - " + e.getMessage());
}
- return jdbcPassword;
}
/** Returns the <datasource> child elements as a Map. */
diff --git a/framework/entity/src/main/java/org/apache/ofbiz/entity/connection/DBCPConnectionFactory.java b/framework/entity/src/main/java/org/apache/ofbiz/entity/connection/DBCPConnectionFactory.java
index 45ffe2ef115..98e2759d5ba 100644
--- a/framework/entity/src/main/java/org/apache/ofbiz/entity/connection/DBCPConnectionFactory.java
+++ b/framework/entity/src/main/java/org/apache/ofbiz/entity/connection/DBCPConnectionFactory.java
@@ -152,6 +152,11 @@ public Connection getConnection(GenericHelperInfo helperInfo, JdbcElement abstra
GenericObjectPool pool = new GenericObjectPool<>(factory, poolConfig);
factory.setPool(pool);
+ try {
+ pool.preparePool();
+ } catch (Exception e) {
+ Debug.logWarning("Could not pre-warm connection pool: " + e.getMessage(), MODULE);
+ }
mds = new DebugManagedDataSource<>(pool, xacf.getTransactionRegistry());
mds.setAccessToUnderlyingConnectionAllowed(true);
From 348d346a91036c77a7b4062d3a042669c154eaef Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Mon, 8 Jun 2026 18:29:16 +0530
Subject: [PATCH 02/30] Adding the support of AES Encryption based password.
Also added the gradle task that will help us in generating the plain or
encrypted password.
---
build.gradle | 26 ++++
.../ofbiz/base/crypto/ConfigCryptoUtil.java | 124 ++++++++++++++++++
.../base/secret/FileBasedSecretProvider.java | 23 ++++
3 files changed, 173 insertions(+)
create mode 100644 framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
diff --git a/build.gradle b/build.gradle
index 73537e4980c..519918d3dc8 100644
--- a/build.gradle
+++ b/build.gradle
@@ -749,6 +749,32 @@ task gitInfoFooter(group: sysadminGroup, description: 'Update the Git Branch-rev
}
}
+task generateDBPassword(group: sysadminGroup,
+ description: 'Write a plain or encrypted (ENC(...)) database password into passwords.properties. '
+ + 'Usage: ./gradlew generateDBPassword -Pvalue= -PlookupKey= [-PmasterKey=]') {
+ doLast {
+ def secret = project.property('value')
+ if (project.hasProperty('masterKey')) {
+ def output = new java.io.ByteArrayOutputStream()
+ javaexec {
+ classpath = sourceSets.main.runtimeClasspath
+ mainClass = 'org.apache.ofbiz.base.crypto.ConfigCryptoUtil'
+ args project.property('masterKey'), secret
+ standardOutput = output
+ }
+ secret = output.toString().trim()
+ }
+ def propertyName = "jdbc-password.${project.property('lookupKey')}"
+ def newLine = "${propertyName}=${secret}"
+ def passwordsFile = file('framework/base/config/passwords.properties')
+ def lines = passwordsFile.readLines()
+ def index = lines.findIndexOf { it.startsWith("${propertyName}=") }
+ if (index >= 0) lines[index] = newLine else lines << newLine
+ passwordsFile.text = lines.join(System.lineSeparator()) + System.lineSeparator()
+ println "Stored ${newLine} in passwords.properties"
+ }
+}
+
task generateSecretKeys(group: sysadminGroup,
description: 'Generate cryptographically secure 512-bit (64-char) secret keys for JWT token signing and password encryption, and write them to security.properties') {
doLast {
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
new file mode 100644
index 00000000000..9a863a08577
--- /dev/null
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
@@ -0,0 +1,124 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.base.crypto;
+
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.apache.ofbiz.base.util.GeneralException;
+
+/**
+ * AES-256-GCM encryption/decryption for configuration values such as the
+ * database passwords stored in {@code passwords.properties} as {@code ENC(...)}.
+ *
+ *
The AES key is derived from a master key (e.g. the {@code OFBIZ_DB_KEY}
+ * environment variable) via PBKDF2WithHmacSHA256, so the same master key always
+ * yields the same AES key. The IV is randomly generated per encryption and
+ * stored alongside the ciphertext, both Base64-encoded together.
+ */
+public final class ConfigCryptoUtil {
+
+ private static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding";
+ private static final String KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256";
+ // Static salt: the master key is the actual secret; the salt only needs to defeat
+ // precomputed rainbow tables, not provide per-installation uniqueness.
+ private static final byte[] SALT = "OFBizConfigCryptoUtilSalt".getBytes(StandardCharsets.UTF_8);
+ private static final int ITERATIONS = 10000;
+ private static final int KEY_LENGTH_BITS = 256;
+ private static final int GCM_TAG_LENGTH_BITS = 128;
+ private static final int IV_LENGTH_BYTES = 12;
+
+ private static SecretKeySpec deriveKey(String masterKey) throws GeneralSecurityException {
+ SecretKeyFactory factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM);
+ PBEKeySpec spec = new PBEKeySpec(masterKey.toCharArray(), SALT, ITERATIONS, KEY_LENGTH_BITS);
+ try {
+ return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), "AES");
+ } finally {
+ spec.clearPassword();
+ }
+ }
+
+ /**
+ * Encrypts {@code plainText} with a key derived from {@code masterKey}.
+ * @return Base64 string containing the random IV followed by the ciphertext (with GCM auth tag)
+ */
+ public static String encrypt(String plainText, String masterKey) throws GeneralException {
+ try {
+ SecretKeySpec secretKey = deriveKey(masterKey);
+ byte[] iv = new byte[IV_LENGTH_BYTES];
+ new SecureRandom().nextBytes(iv);
+ Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
+ byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
+ byte[] payload = new byte[IV_LENGTH_BYTES + cipherText.length];
+ System.arraycopy(iv, 0, payload, 0, IV_LENGTH_BYTES);
+ System.arraycopy(cipherText, 0, payload, IV_LENGTH_BYTES, cipherText.length);
+ return Base64.getEncoder().encodeToString(payload);
+ } catch (GeneralSecurityException e) {
+ throw new GeneralException("Unable to encrypt value", e);
+ }
+ }
+
+ /**
+ * Reverses {@link #encrypt(String, String)}: derives the AES key from {@code masterKey},
+ * splits the IV from the ciphertext and decrypts.
+ */
+ public static String decrypt(String encryptedBase64, String masterKey) throws GeneralException {
+ try {
+ byte[] payload = Base64.getDecoder().decode(encryptedBase64);
+ if (payload.length <= IV_LENGTH_BYTES) {
+ throw new GeneralException("Encrypted value is too short to contain an IV and ciphertext");
+ }
+ byte[] iv = Arrays.copyOfRange(payload, 0, IV_LENGTH_BYTES);
+ byte[] cipherText = Arrays.copyOfRange(payload, IV_LENGTH_BYTES, payload.length);
+ SecretKeySpec secretKey = deriveKey(masterKey);
+ Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+ cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
+ return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
+ } catch (GeneralSecurityException | IllegalArgumentException e) {
+ throw new GeneralException("Unable to decrypt value: verify the master key is correct", e);
+ }
+ }
+
+ private ConfigCryptoUtil() { }
+
+ /**
+ * Command-line helper that prints {@code ENC()} for a given master key and plaintext
+ * value, ready to be pasted into {@code passwords.properties} (e.g. as
+ * {@code jdbc-password.=ENC(...)}). Invoked via the {@code encryptDbPassword}
+ * Gradle task.
+ */
+ public static void main(String[] args) throws GeneralException {
+ if (args.length != 2) {
+ System.err.println("Usage: ConfigCryptoUtil ");
+ System.exit(1);
+ return;
+ }
+ System.out.println("ENC(" + encrypt(args[1], args[0]) + ")");
+ }
+}
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
index 2879830d5e9..3d5a927cccf 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
@@ -18,6 +18,7 @@
*******************************************************************************/
package org.apache.ofbiz.base.secret;
+import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilProperties;
import org.apache.ofbiz.base.util.UtilValidate;
@@ -29,15 +30,37 @@
*
This preserves full backward compatibility with the existing
* {@code jdbc-password-lookup} mechanism in {@code entityengine.xml}.
* No configuration is required to use this implementation.
+ *
+ *
Values may optionally be stored encrypted, wrapped as {@code ENC()},
+ * e.g. {@code jdbc-password.mysql-ofbiz=ENC(AbCd123...)}. Encrypted values are
+ * decrypted in-memory with {@link ConfigCryptoUtil} using the master key supplied
+ * via the {@code OFBIZ_DB_KEY} environment variable.
*/
public final class FileBasedSecretProvider implements SecretProvider {
+ private static final String ENC_PREFIX = "ENC(";
+ private static final String ENC_SUFFIX = ")";
+ private static final String MASTER_KEY_ENV_VAR = "OFBIZ_DB_KEY";
+
@Override
public String getSecret(String key) throws GeneralException {
String value = UtilProperties.getPropertyValue("passwords", key);
if (UtilValidate.isEmpty(value)) {
throw new GeneralException("Secret key '" + key + "' not found in passwords.properties");
}
+ if (value.startsWith(ENC_PREFIX) && value.endsWith(ENC_SUFFIX)) {
+ String masterKey = System.getenv(MASTER_KEY_ENV_VAR);
+ if (UtilValidate.isEmpty(masterKey)) {
+ throw new GeneralException("Secret '" + key + "' is encrypted but the " + MASTER_KEY_ENV_VAR
+ + " environment variable holding the master key is not set");
+ }
+ String encryptedValue = value.substring(ENC_PREFIX.length(), value.length() - ENC_SUFFIX.length());
+ try {
+ return ConfigCryptoUtil.decrypt(encryptedValue, masterKey);
+ } catch (GeneralException e) {
+ throw new GeneralException("Failed to decrypt secret '" + key + "': " + e.getMessage(), e);
+ }
+ }
return value;
}
}
From dc09410a31c65c652bc381c2389eb2a2095d01b5 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Tue, 9 Jun 2026 15:11:23 +0530
Subject: [PATCH 03/30] 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.
---
.../ofbiz/base/crypto/ConfigCryptoUtil.java | 33 +++++++++++++++++++
.../base/secret/FileBasedSecretProvider.java | 19 +----------
.../ofbiz/base/secret/SecretProvider.java | 9 +++++
3 files changed, 43 insertions(+), 18 deletions(-)
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
index 9a863a08577..244a5c4dc68 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
@@ -105,6 +105,39 @@ public static String decrypt(String encryptedBase64, String masterKey) throws Ge
}
}
+ /**
+ * Returns the plaintext of {@code rawValue} if it is wrapped in {@code ENC(...)},
+ * otherwise returns it unchanged.
+ *
+ *
All {@link org.apache.ofbiz.base.secret.SecretProvider} implementations should
+ * call this after fetching a value from their remote backend so that operators can
+ * optionally add a client-side AES-256-GCM layer on top of whatever the vault already
+ * provides. The master key is read from the {@code OFBIZ_DB_KEY} environment variable.
+ *
+ * @param rawValue the value as returned by the secret backend (plaintext or {@code ENC(...)})
+ * @param secretName used only in error messages to identify which secret failed
+ * @return the plaintext value — either the original string or the decrypted one
+ * @throws GeneralException if the value is encrypted but {@code OFBIZ_DB_KEY} is missing,
+ * or if decryption fails (e.g. wrong key)
+ */
+ public static String decryptIfEncrypted(String rawValue, String secretName) throws GeneralException {
+ if (!rawValue.startsWith("ENC(") || !rawValue.endsWith(")")) {
+ return rawValue;
+ }
+ String masterKey = System.getenv("OFBIZ_DB_KEY");
+ if (masterKey == null || masterKey.isEmpty()) {
+ throw new GeneralException("Secret '" + secretName + "' is encrypted (ENC(...)) but the "
+ + "OFBIZ_DB_KEY environment variable is not set");
+ }
+ String base64 = rawValue.substring("ENC(".length(), rawValue.length() - 1);
+ try {
+ return decrypt(base64, masterKey);
+ } catch (GeneralException e) {
+ throw new GeneralException("Failed to decrypt secret '" + secretName
+ + "': verify OFBIZ_DB_KEY matches the key used to encrypt", e);
+ }
+ }
+
private ConfigCryptoUtil() { }
/**
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
index 3d5a927cccf..4152b53f19d 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
@@ -38,29 +38,12 @@
*/
public final class FileBasedSecretProvider implements SecretProvider {
- private static final String ENC_PREFIX = "ENC(";
- private static final String ENC_SUFFIX = ")";
- private static final String MASTER_KEY_ENV_VAR = "OFBIZ_DB_KEY";
-
@Override
public String getSecret(String key) throws GeneralException {
String value = UtilProperties.getPropertyValue("passwords", key);
if (UtilValidate.isEmpty(value)) {
throw new GeneralException("Secret key '" + key + "' not found in passwords.properties");
}
- if (value.startsWith(ENC_PREFIX) && value.endsWith(ENC_SUFFIX)) {
- String masterKey = System.getenv(MASTER_KEY_ENV_VAR);
- if (UtilValidate.isEmpty(masterKey)) {
- throw new GeneralException("Secret '" + key + "' is encrypted but the " + MASTER_KEY_ENV_VAR
- + " environment variable holding the master key is not set");
- }
- String encryptedValue = value.substring(ENC_PREFIX.length(), value.length() - ENC_SUFFIX.length());
- try {
- return ConfigCryptoUtil.decrypt(encryptedValue, masterKey);
- } catch (GeneralException e) {
- throw new GeneralException("Failed to decrypt secret '" + key + "': " + e.getMessage(), e);
- }
- }
- return value;
+ return ConfigCryptoUtil.decryptIfEncrypted(value, key);
}
}
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
index 11b1f868510..b042fa67b01 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
@@ -35,6 +35,15 @@
*
Any provider implementation may store secret values wrapped in
+ * {@code ENC()} to add a client-side AES-256-GCM encryption layer on
+ * top of whatever the remote vault already provides. Call
+ * {@code ConfigCryptoUtil.decryptIfEncrypted()} on the raw value returned by
+ * the remote API before caching or returning it. The master key is read from
+ * the {@code OFBIZ_DB_KEY} environment variable at runtime and is never stored
+ * in config files or in the remote vault.
*/
public interface SecretProvider {
From 653ee9738ae4c6e33be8fd46343f58e445b61a87 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 10 Jun 2026 00:19:35 +0530
Subject: [PATCH 04/30] Changing -Pvalue to -PdbPassword for clarity.
---
build.gradle | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/build.gradle b/build.gradle
index 519918d3dc8..a940122550f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -751,9 +751,9 @@ task gitInfoFooter(group: sysadminGroup, description: 'Update the Git Branch-rev
task generateDBPassword(group: sysadminGroup,
description: 'Write a plain or encrypted (ENC(...)) database password into passwords.properties. '
- + 'Usage: ./gradlew generateDBPassword -Pvalue= -PlookupKey= [-PmasterKey=]') {
+ + 'Usage: ./gradlew generateDBPassword -PdbPassword= -PlookupKey= [-PmasterKey=]') {
doLast {
- def secret = project.property('value')
+ def secret = project.property('dbPassword')
if (project.hasProperty('masterKey')) {
def output = new java.io.ByteArrayOutputStream()
javaexec {
From fd38a3c13179464e325f94eb093f1edd7b4fc05a Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 10 Jun 2026 17:41:08 +0530
Subject: [PATCH 05/30] 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.
---
.../apache/ofbiz/base/secret/SecretProvider.java | 16 ++++++++++++++++
.../ofbiz/base/secret/SecretProviderFactory.java | 9 ++++++++-
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
index b042fa67b01..01c97aa7fc9 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
@@ -55,4 +55,20 @@ public interface SecretProvider {
* @throws GeneralException if the secret cannot be found or an error occurs during resolution
*/
String getSecret(String key) throws GeneralException;
+
+ /**
+ * Returns whether {@link FallbackSecretProvider} is allowed to fall back to
+ * {@link FileBasedSecretProvider} (i.e. {@code passwords.properties}) when this
+ * provider fails to resolve a secret.
+ *
+ *
Implementations should read this from their own plugin configuration
+ * resource (e.g. {@code .fallback.enabled}), defaulting
+ * to {@code true}.
+ *
+ * @return {@code true} if local fallback is permitted, {@code false} to require
+ * this provider to be the sole source of secrets
+ */
+ default boolean isFallbackEnabled() {
+ return true;
+ }
}
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
index 9d8ec088acd..6f2191d133c 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
@@ -33,6 +33,13 @@
* If none is found, {@link FileBasedSecretProvider} is used automatically,
* preserving backward compatibility with {@code passwords.properties}.
*
+ *
If a custom (remote) provider is found, it is wrapped in a
+ * {@link FallbackSecretProvider} together with {@link FileBasedSecretProvider}.
+ * When the remote provider's own {@code .fallback.enabled} configuration
+ * property is {@code true} (the default) and the remote provider fails (e.g. the
+ * remote secret manager is unreachable), the secret is resolved from the local
+ * {@code passwords.properties} file instead.
+ *
*
This mirrors the pattern already used by
* {@link org.apache.ofbiz.security.SecurityFactory}.
*/
@@ -48,7 +55,7 @@ private static SecretProvider loadProvider() {
if (it.hasNext()) {
SecretProvider provider = it.next();
Debug.logInfo("SecretProvider: using custom implementation " + provider.getClass().getName(), MODULE);
- return provider;
+ return new FallbackSecretProvider(provider, new FileBasedSecretProvider());
}
Debug.logInfo("SecretProvider: no custom implementation found, using FileBasedSecretProvider", MODULE);
return new FileBasedSecretProvider();
From b9d4683dd81043090186e0fe25ac4a70298f0f86 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Thu, 11 Jun 2026 12:41:54 +0530
Subject: [PATCH 06/30] Adding the fallback option for all the plugins. And
also improved console warnings, now it will return file name as well if it's
falling back to file based credentials if all the plugins are disabled.
---
.../base/secret/FallbackSecretProvider.java | 82 +++++++++++++++++++
.../base/secret/SecretProviderFactory.java | 3 +-
2 files changed, 84 insertions(+), 1 deletion(-)
create mode 100644 framework/base/src/main/java/org/apache/ofbiz/base/secret/FallbackSecretProvider.java
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/FallbackSecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FallbackSecretProvider.java
new file mode 100644
index 00000000000..369608f78df
--- /dev/null
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FallbackSecretProvider.java
@@ -0,0 +1,82 @@
+/*******************************************************************************
+ * 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.base.secret;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+
+/**
+ * {@link SecretProvider} decorator that falls back to a secondary provider
+ * when the primary provider fails to resolve a secret.
+ *
+ *
This is used by {@link SecretProviderFactory} to wrap a custom (remote)
+ * {@link SecretProvider} implementation together with a
+ * {@link FileBasedSecretProvider}. If the primary provider throws a
+ * {@link GeneralException} (e.g. the remote secret manager is unreachable or
+ * the secret does not exist there) and {@link SecretProvider#isFallbackEnabled()}
+ * returns {@code true} for the primary provider, the secret is resolved from
+ * the fallback provider instead.
+ *
+ *
If the primary provider fails and fallback is disabled, or the fallback
+ * provider also fails, the original exception from the primary provider is
+ * propagated to the caller.
+ */
+public final class FallbackSecretProvider implements SecretProvider {
+
+ private static final String MODULE = FallbackSecretProvider.class.getName();
+
+ private final SecretProvider primary;
+ private final SecretProvider fallback;
+
+ /**
+ * @param primary the primary (typically remote) provider to try first
+ * @param fallback the provider to use if the primary fails and allows fallback
+ */
+ public FallbackSecretProvider(SecretProvider primary, SecretProvider fallback) {
+ this.primary = primary;
+ this.fallback = fallback;
+ }
+
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ try {
+ return primary.getSecret(key);
+ } catch (GeneralException e) {
+ if (!primary.isFallbackEnabled()) {
+ throw e;
+ }
+ Debug.logWarning("SecretProvider: " + primary.getClass().getName()
+ + " failed to resolve secret '" + key + "' (" + e.getMessage()
+ + "), falling back to " + describe(fallback), MODULE);
+ return fallback.getSecret(key);
+ }
+ }
+
+ /**
+ * Returns a human-readable description of the given provider for log
+ * messages, naming the backing {@code passwords.properties} file for
+ * {@link FileBasedSecretProvider}.
+ */
+ private static String describe(SecretProvider provider) {
+ if (provider instanceof FileBasedSecretProvider) {
+ return provider.getClass().getName() + " (framework/base/config/passwords.properties)";
+ }
+ return provider.getClass().getName();
+ }
+}
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
index 6f2191d133c..5e5fef791de 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
@@ -57,7 +57,8 @@ private static SecretProvider loadProvider() {
Debug.logInfo("SecretProvider: using custom implementation " + provider.getClass().getName(), MODULE);
return new FallbackSecretProvider(provider, new FileBasedSecretProvider());
}
- Debug.logInfo("SecretProvider: no custom implementation found, using FileBasedSecretProvider", MODULE);
+ Debug.logInfo("SecretProvider: no custom implementation found, using FileBasedSecretProvider"
+ + " (framework/base/config/passwords.properties)", MODULE);
return new FileBasedSecretProvider();
}
From 3a75ccfdb4115903973174248f97b9530be6c5d1 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Thu, 11 Jun 2026 18:03:54 +0530
Subject: [PATCH 07/30] Improved: Use Shiro's AesCipherService for
ConfigCryptoUtil encryption
Replace the hand-rolled javax.crypto AES/GCM/NoPadding implementation in
ConfigCryptoUtil with Apache Shiro's AesCipherService (AES-256-GCM), the
same crypto library already used by EntityCrypto for entity field-level
encryption. This standardizes the encryption approach across the codebase
and removes duplicate, manually-managed cipher/IV handling code.
PBKDF2WithHmacSHA256 key derivation from OFBIZ_DB_KEY is retained unchanged.
Note: the on-disk format of ENC(...) values changes (Shiro uses a 16-byte
IV vs the previous 12-byte IV), so any existing encrypted values in
passwords.properties or external secret stores must be regenerated using
the generateDBPassword Gradle task. I have tested out it and everything is working fine.
---
.../ofbiz/base/crypto/ConfigCryptoUtil.java | 59 ++++++++-----------
1 file changed, 24 insertions(+), 35 deletions(-)
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
index 244a5c4dc68..171f23f1020 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
@@ -20,17 +20,15 @@
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
-import java.security.SecureRandom;
-import java.util.Arrays;
import java.util.Base64;
-import javax.crypto.Cipher;
import javax.crypto.SecretKeyFactory;
-import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
-import javax.crypto.spec.SecretKeySpec;
import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.shiro.crypto.CryptoException;
+import org.apache.shiro.crypto.cipher.AesCipherService;
+import org.apache.shiro.lang.util.ByteSource;
/**
* AES-256-GCM encryption/decryption for configuration values such as the
@@ -38,26 +36,30 @@
*
*
The AES key is derived from a master key (e.g. the {@code OFBIZ_DB_KEY}
* environment variable) via PBKDF2WithHmacSHA256, so the same master key always
- * yields the same AES key. The IV is randomly generated per encryption and
- * stored alongside the ciphertext, both Base64-encoded together.
+ * yields the same AES key. The actual cipher operations are delegated to Shiro's
+ * {@link AesCipherService} (the same library used by
+ * {@link org.apache.ofbiz.entity.util.EntityCrypto}), which defaults to GCM mode
+ * and prepends a random IV to the ciphertext.
*/
public final class ConfigCryptoUtil {
- private static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding";
private static final String KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256";
// Static salt: the master key is the actual secret; the salt only needs to defeat
// precomputed rainbow tables, not provide per-installation uniqueness.
private static final byte[] SALT = "OFBizConfigCryptoUtilSalt".getBytes(StandardCharsets.UTF_8);
private static final int ITERATIONS = 10000;
private static final int KEY_LENGTH_BITS = 256;
- private static final int GCM_TAG_LENGTH_BITS = 128;
- private static final int IV_LENGTH_BYTES = 12;
- private static SecretKeySpec deriveKey(String masterKey) throws GeneralSecurityException {
+ // Note: AesCipherService derives the GCM tag length from getKeySize(), which must stay at
+ // its default of 128 (a valid GCM tag length). The 256-bit AES key below is passed directly
+ // to encrypt/decrypt and does not depend on this setting.
+ private static final AesCipherService CIPHER_SERVICE = new AesCipherService();
+
+ private static byte[] deriveKey(String masterKey) throws GeneralSecurityException {
SecretKeyFactory factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM);
PBEKeySpec spec = new PBEKeySpec(masterKey.toCharArray(), SALT, ITERATIONS, KEY_LENGTH_BITS);
try {
- return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), "AES");
+ return factory.generateSecret(spec).getEncoded();
} finally {
spec.clearPassword();
}
@@ -69,38 +71,25 @@ private static SecretKeySpec deriveKey(String masterKey) throws GeneralSecurityE
*/
public static String encrypt(String plainText, String masterKey) throws GeneralException {
try {
- SecretKeySpec secretKey = deriveKey(masterKey);
- byte[] iv = new byte[IV_LENGTH_BYTES];
- new SecureRandom().nextBytes(iv);
- Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
- cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
- byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
- byte[] payload = new byte[IV_LENGTH_BYTES + cipherText.length];
- System.arraycopy(iv, 0, payload, 0, IV_LENGTH_BYTES);
- System.arraycopy(cipherText, 0, payload, IV_LENGTH_BYTES, cipherText.length);
- return Base64.getEncoder().encodeToString(payload);
- } catch (GeneralSecurityException e) {
+ byte[] key = deriveKey(masterKey);
+ ByteSource encrypted = CIPHER_SERVICE.encrypt(plainText.getBytes(StandardCharsets.UTF_8), key);
+ return encrypted.toBase64();
+ } catch (GeneralSecurityException | CryptoException e) {
throw new GeneralException("Unable to encrypt value", e);
}
}
/**
- * Reverses {@link #encrypt(String, String)}: derives the AES key from {@code masterKey},
- * splits the IV from the ciphertext and decrypts.
+ * Reverses {@link #encrypt(String, String)}: derives the AES key from {@code masterKey}
+ * and lets {@link AesCipherService} split the IV from the ciphertext and decrypt.
*/
public static String decrypt(String encryptedBase64, String masterKey) throws GeneralException {
try {
+ byte[] key = deriveKey(masterKey);
byte[] payload = Base64.getDecoder().decode(encryptedBase64);
- if (payload.length <= IV_LENGTH_BYTES) {
- throw new GeneralException("Encrypted value is too short to contain an IV and ciphertext");
- }
- byte[] iv = Arrays.copyOfRange(payload, 0, IV_LENGTH_BYTES);
- byte[] cipherText = Arrays.copyOfRange(payload, IV_LENGTH_BYTES, payload.length);
- SecretKeySpec secretKey = deriveKey(masterKey);
- Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
- cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
- return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
- } catch (GeneralSecurityException | IllegalArgumentException e) {
+ byte[] decrypted = CIPHER_SERVICE.decrypt(payload, key).getClonedBytes();
+ return new String(decrypted, StandardCharsets.UTF_8);
+ } catch (GeneralSecurityException | CryptoException | IllegalArgumentException e) {
throw new GeneralException("Unable to decrypt value: verify the master key is correct", e);
}
}
From 4de4287a99e402dbc7e064500d138b7201100ec0 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Fri, 12 Jun 2026 18:38:02 +0530
Subject: [PATCH 08/30] Adding a very important feature which I envisioned
before I started this work to add support of secret manager/vaults in ofbiz.
In previous commits, We added the six secret manager/vault support in ofbiz.
In this commit, I am adding the support of the following points:
1) Add SecretValueResolver to resolve SECRET(key) markers in .properties values via SecretProviderFactory, with TTL cache and passwords.properties fallback.
2) UtilProperties.getPropertyValue now passes resolved values through SecretValueResolver.
3) Add SystemProperty.systemPropertyLookup field; EntityUtilProperties resolves it via SecretValueResolver, falling back to systemPropertyValue
(ENC(...) via ConfigCryptoUtil, or plain text).
4) Rename OFBIZ_DB_KEY to OFBIZ_MASTER_KEY across ConfigCryptoUtil, SecretProvider SPI, and
docs.
5) Add generateEncryptedSecret Gradle task to produce systemPropertyLookup/systemPropertyValue pairs for SystemProperty rows.
6) Add INFO logging in EntityUtilProperties indicating whether a resolved value came from systemPropertyLookup or systemPropertyValue.
7) jdbc-password-lookup and EntityConfig.getJdbcPassword() remain unchanged and fully backward compatible.
8) Add unit tests for SecretValueResolver.
---
.../ofbiz/base/crypto/ConfigCryptoUtil.java | 12 ++---
.../base/secret/FileBasedSecretProvider.java | 2 +-
.../ofbiz/base/secret/SecretProvider.java | 2 +-
.../ofbiz/base/util/UtilProperties.java | 3 +-
framework/common/entitydef/entitymodel.xml | 3 +-
.../entity/util/EntityUtilProperties.java | 48 ++++++++++++++++++-
6 files changed, 58 insertions(+), 12 deletions(-)
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
index 171f23f1020..ed4b2dd47ee 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
@@ -34,7 +34,7 @@
* AES-256-GCM encryption/decryption for configuration values such as the
* database passwords stored in {@code passwords.properties} as {@code ENC(...)}.
*
- *
The AES key is derived from a master key (e.g. the {@code OFBIZ_DB_KEY}
+ *
The AES key is derived from a master key (e.g. the {@code OFBIZ_MASTER_KEY}
* environment variable) via PBKDF2WithHmacSHA256, so the same master key always
* yields the same AES key. The actual cipher operations are delegated to Shiro's
* {@link AesCipherService} (the same library used by
@@ -101,29 +101,29 @@ public static String decrypt(String encryptedBase64, String masterKey) throws Ge
*
All {@link org.apache.ofbiz.base.secret.SecretProvider} implementations should
* call this after fetching a value from their remote backend so that operators can
* optionally add a client-side AES-256-GCM layer on top of whatever the vault already
- * provides. The master key is read from the {@code OFBIZ_DB_KEY} environment variable.
+ * provides. The master key is read from the {@code OFBIZ_MASTER_KEY} environment variable.
*
* @param rawValue the value as returned by the secret backend (plaintext or {@code ENC(...)})
* @param secretName used only in error messages to identify which secret failed
* @return the plaintext value — either the original string or the decrypted one
- * @throws GeneralException if the value is encrypted but {@code OFBIZ_DB_KEY} is missing,
+ * @throws GeneralException if the value is encrypted but {@code OFBIZ_MASTER_KEY} is missing,
* or if decryption fails (e.g. wrong key)
*/
public static String decryptIfEncrypted(String rawValue, String secretName) throws GeneralException {
if (!rawValue.startsWith("ENC(") || !rawValue.endsWith(")")) {
return rawValue;
}
- String masterKey = System.getenv("OFBIZ_DB_KEY");
+ String masterKey = System.getenv("OFBIZ_MASTER_KEY");
if (masterKey == null || masterKey.isEmpty()) {
throw new GeneralException("Secret '" + secretName + "' is encrypted (ENC(...)) but the "
- + "OFBIZ_DB_KEY environment variable is not set");
+ + "OFBIZ_MASTER_KEY environment variable is not set");
}
String base64 = rawValue.substring("ENC(".length(), rawValue.length() - 1);
try {
return decrypt(base64, masterKey);
} catch (GeneralException e) {
throw new GeneralException("Failed to decrypt secret '" + secretName
- + "': verify OFBIZ_DB_KEY matches the key used to encrypt", e);
+ + "': verify OFBIZ_MASTER_KEY matches the key used to encrypt", e);
}
}
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
index 4152b53f19d..6bcffc8cb07 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java
@@ -34,7 +34,7 @@
*
Values may optionally be stored encrypted, wrapped as {@code ENC()},
* e.g. {@code jdbc-password.mysql-ofbiz=ENC(AbCd123...)}. Encrypted values are
* decrypted in-memory with {@link ConfigCryptoUtil} using the master key supplied
- * via the {@code OFBIZ_DB_KEY} environment variable.
+ * via the {@code OFBIZ_MASTER_KEY} environment variable.
*/
public final class FileBasedSecretProvider implements SecretProvider {
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
index 01c97aa7fc9..c431f21d3be 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
@@ -42,7 +42,7 @@
* top of whatever the remote vault already provides. Call
* {@code ConfigCryptoUtil.decryptIfEncrypted()} on the raw value returned by
* the remote API before caching or returning it. The master key is read from
- * the {@code OFBIZ_DB_KEY} environment variable at runtime and is never stored
+ * the {@code OFBIZ_MASTER_KEY} environment variable at runtime and is never stored
* in config files or in the remote vault.
*/
public interface SecretProvider {
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilProperties.java b/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilProperties.java
index ce9b63e7423..b78cbf32dde 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilProperties.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilProperties.java
@@ -43,6 +43,7 @@
import java.util.Set;
import org.apache.ofbiz.base.location.FlexibleLocation;
+import org.apache.ofbiz.base.secret.SecretValueResolver;
import org.apache.ofbiz.base.util.cache.UtilCache;
import org.apache.ofbiz.base.util.collections.ResourceBundleMapWrapper;
import org.apache.ofbiz.base.util.string.FlexibleStringExpander;
@@ -287,7 +288,7 @@ public static String getPropertyValue(String resource, String name) {
} catch (Exception e) {
Debug.logInfo(e, MODULE);
}
- return value == null ? "" : value.trim();
+ return value == null ? "" : SecretValueResolver.resolve(value.trim());
}
/**
diff --git a/framework/common/entitydef/entitymodel.xml b/framework/common/entitydef/entitymodel.xml
index 47385cc61a8..760c7606618 100644
--- a/framework/common/entitydef/entitymodel.xml
+++ b/framework/common/entitydef/entitymodel.xml
@@ -883,11 +883,12 @@ under the License.
-
+
+
diff --git a/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtilProperties.java b/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtilProperties.java
index 1a6a6e90071..11a9b491a3c 100644
--- a/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtilProperties.java
+++ b/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtilProperties.java
@@ -34,7 +34,10 @@
import java.util.ResourceBundle;
import java.util.Set;
+import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
+import org.apache.ofbiz.base.secret.SecretValueResolver;
import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilMisc;
import org.apache.ofbiz.base.util.UtilProperties;
import org.apache.ofbiz.base.util.UtilValidate;
@@ -69,8 +72,7 @@ private static Map getSystemPropertyValue(String resource, Strin
if (systemProperty != null) {
//property exists in database
results.put("isExistInDb", "Y");
- results.put("value", (systemProperty.getString("systemPropertyValue") != null)
- ? systemProperty.getString("systemPropertyValue") : "");
+ results.put("value", resolveSystemPropertyValue(resource, name, systemProperty));
}
} catch (GenericEntityException e) {
Debug.logError("Could not get a system property for " + name + " : " + e.getMessage(), MODULE);
@@ -78,6 +80,48 @@ private static Map getSystemPropertyValue(String resource, Strin
return results;
}
+ /**
+ * Resolves the effective value of a {@code SystemProperty} record:
+ *
+ *
if {@code systemPropertyLookup} holds a {@code SECRET(key)} reference (see
+ * {@link SecretValueResolver}), resolves {@code key} via the configured
+ * {@code SecretProvider} (a remote secret manager, with its own
+ * {@code passwords.properties} fallback); if that fails, falls back to
+ * {@code systemPropertyValue}
+ *
otherwise (or on lookup failure), uses {@code systemPropertyValue}, decrypting it
+ * with {@link ConfigCryptoUtil} if it is wrapped in {@code ENC(...)}
+ *
+ */
+ private static String resolveSystemPropertyValue(String resource, String name, GenericValue systemProperty) {
+ String lookup = systemProperty.getString("systemPropertyLookup");
+ if (UtilValidate.isNotEmpty(lookup)) {
+ Debug.logInfo("EntityUtilProperties: resolving " + resource + "/" + name
+ + " via SystemProperty.systemPropertyLookup '" + lookup + "'", MODULE);
+ String resolved = SecretValueResolver.resolve(lookup);
+ if (UtilValidate.isNotEmpty(resolved) && !resolved.equals(lookup)) {
+ return resolved;
+ }
+ Debug.logWarning("EntityUtilProperties: systemPropertyLookup '" + lookup + "' for " + resource + "/" + name
+ + " did not resolve, falling back to systemPropertyValue", MODULE);
+ }
+ String value = systemProperty.getString("systemPropertyValue");
+ if (value == null) {
+ return "";
+ }
+ try {
+ String decrypted = ConfigCryptoUtil.decryptIfEncrypted(value, resource + "." + name);
+ if (!decrypted.equals(value)) {
+ Debug.logInfo("EntityUtilProperties: resolved " + resource + "/" + name
+ + " from SystemProperty.systemPropertyValue (decrypted ENC(...) value)", MODULE);
+ }
+ return decrypted;
+ } catch (GeneralException e) {
+ Debug.logError("EntityUtilProperties: failed to decrypt systemPropertyValue for " + resource + "/" + name
+ + ": " + e.getMessage(), MODULE);
+ return "";
+ }
+ }
+
public static boolean propertyValueEquals(String resource, String name, String compareString) {
return UtilProperties.propertyValueEquals(resource, name, compareString);
}
From bdc41a1569f8fd98d043e0f13bf602a79e550dd0 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Fri, 12 Jun 2026 18:41:04 +0530
Subject: [PATCH 09/30] Adding the two files SecretValueResolver.java and
SecretValueResolverTest.java that I missed in my previous commit.
---
.../base/secret/SecretValueResolver.java | 122 ++++++++++++++++++
.../base/secret/SecretValueResolverTest.java | 57 ++++++++
2 files changed, 179 insertions(+)
create mode 100644 framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java
create mode 100644 framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java
new file mode 100644
index 00000000000..6cd5b6c6a64
--- /dev/null
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java
@@ -0,0 +1,122 @@
+/*******************************************************************************
+ * 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.base.secret;
+
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
+
+/**
+ * Resolves configuration values of the form {@code SECRET(key)} to the
+ * corresponding secret value via {@link SecretProviderFactory}.
+ *
+ *
This allows any property read through {@code UtilProperties} or
+ * {@code EntityUtilProperties} (e.g. payment, SMS or shipment gateway
+ * credentials stored in a {@code .properties} file or as a
+ * {@code SystemProperty}) to be backed by a remote secret manager, using the
+ * same {@link SecretProvider} SPI and providers already used for
+ * {@code jdbc-password-lookup}.
+ *
+ *
Values that do not match {@code SECRET(key)} are returned unchanged.
+ * The {@code jdbc-password-lookup} mechanism in {@code entityengine.xml}
+ * ({@code EntityConfig.getJdbcPassword()}) does not go through this class and
+ * is unaffected.
+ *
+ *
The marker name ({@code SECRET} by default) can be changed via the
+ * {@code secret.value.marker} property in {@code general.properties}, e.g. to
+ * {@code MASK} or {@code ENCRYPT}, in case it collides with existing property
+ * values.
+ *
+ *
Resolved values are cached for {@code secret.cache.ttl.seconds}
+ * (default 300) to avoid calling the remote secret manager on every property
+ * lookup.
+ */
+public final class SecretValueResolver {
+
+ private static final String MODULE = SecretValueResolver.class.getName();
+
+ // Read the marker name and cache TTL directly from the Properties object, bypassing
+ // UtilProperties.getPropertyValue(), which calls back into resolve() and would otherwise
+ // deadlock/NPE on these fields during static initialization.
+ private static final Properties GENERAL_PROPERTIES = UtilProperties.getProperties("general");
+
+ private static final String MARKER_NAME = GENERAL_PROPERTIES != null
+ ? GENERAL_PROPERTIES.getProperty("secret.value.marker", "SECRET").trim()
+ : "SECRET";
+
+ private static final Pattern SECRET_PATTERN =
+ Pattern.compile("^" + Pattern.quote(MARKER_NAME) + "\\((.+)\\)$");
+
+ private static final long CACHE_TTL_MILLIS = (GENERAL_PROPERTIES != null
+ ? Long.parseLong(GENERAL_PROPERTIES.getProperty("secret.cache.ttl.seconds", "300").trim())
+ : 300L) * 1000L;
+
+ private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>();
+
+ private SecretValueResolver() { }
+
+ /**
+ * If {@code rawValue} matches {@code SECRET(key)}, resolves {@code key} via
+ * {@link SecretProviderFactory#getInstance()}, caching the result for
+ * {@code secret.cache.ttl.seconds}. Otherwise returns {@code rawValue} unchanged.
+ *
+ * @param rawValue the raw property value, possibly {@code null}
+ * @return the resolved secret value, or {@code rawValue} unchanged if it is not a
+ * {@code SECRET(key)} reference; an empty string if resolution fails
+ */
+ public static String resolve(String rawValue) {
+ if (rawValue == null) {
+ return null;
+ }
+ Matcher matcher = SECRET_PATTERN.matcher(rawValue.trim());
+ if (!matcher.matches()) {
+ return rawValue;
+ }
+ String key = matcher.group(1).trim();
+
+ CacheEntry cached = CACHE.get(key);
+ if (cached != null && cached.expiry > System.currentTimeMillis()) {
+ return cached.value;
+ }
+
+ try {
+ String secret = SecretProviderFactory.getInstance().getSecret(key);
+ CACHE.put(key, new CacheEntry(secret, System.currentTimeMillis() + CACHE_TTL_MILLIS));
+ return secret;
+ } catch (GeneralException e) {
+ Debug.logError("SecretValueResolver: failed to resolve secret '" + key + "': " + e.getMessage(), MODULE);
+ return "";
+ }
+ }
+
+ private static final class CacheEntry {
+ private final String value;
+ private final long expiry;
+
+ private CacheEntry(String value, long expiry) {
+ this.value = value;
+ this.expiry = expiry;
+ }
+ }
+}
diff --git a/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java
new file mode 100644
index 00000000000..ab9e3d95776
--- /dev/null
+++ b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java
@@ -0,0 +1,57 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.base.secret;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import org.junit.Test;
+
+/**
+ * Tests {@link SecretValueResolver} using the default {@link FileBasedSecretProvider}
+ * (resolved via {@link SecretProviderFactory} since no plugin-provided {@link SecretProvider}
+ * is on the test classpath) and the {@code jdbc-password.h2-ofbiz} entry already present in
+ * {@code framework/base/config/passwords.properties}.
+ */
+public class SecretValueResolverTest {
+
+ @Test
+ public void nonSecretValuesAreReturnedUnchanged() {
+ assertEquals("plainvalue", SecretValueResolver.resolve("plainvalue"));
+ assertEquals("ENC(AbCd123)", SecretValueResolver.resolve("ENC(AbCd123)"));
+ assertEquals("", SecretValueResolver.resolve(""));
+ }
+
+ @Test
+ public void nullIsReturnedUnchanged() {
+ assertNull(SecretValueResolver.resolve(null));
+ }
+
+ @Test
+ public void resolvesKnownKeyFromPasswordsProperties() {
+ assertEquals("ofbiz", SecretValueResolver.resolve("SECRET(jdbc-password.h2-ofbiz)"));
+ // Cached lookup should return the same value.
+ assertEquals("ofbiz", SecretValueResolver.resolve("SECRET(jdbc-password.h2-ofbiz)"));
+ }
+
+ @Test
+ public void unresolvableKeyReturnsEmptyString() {
+ assertEquals("", SecretValueResolver.resolve("SECRET(jdbc-password.does-not-exist)"));
+ }
+}
From a490fd7c0043b97693a73a4cc85e8a2b35a6e57e Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Fri, 12 Jun 2026 23:56:05 +0530
Subject: [PATCH 10/30] Committing the missed build.gradle file in which I
added a new gradle task to generate encrypted secrets/password on the console
and that can be used to setup data in SystemProperty file.
---
build.gradle | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/build.gradle b/build.gradle
index a940122550f..71672527c9b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -775,6 +775,35 @@ task generateDBPassword(group: sysadminGroup,
}
}
+task generateEncryptedSecret(group: sysadminGroup,
+ description: 'Generate an encrypted secret (ENC(...)) and its systemPropertyLookup marker, for pasting '
+ + 'into a SystemProperty record (e.g. payment/SMS/shipment gateway credentials). '
+ + 'Usage: ./gradlew generateEncryptedSecret -PsecretPassword= -PlookupKey= -PmasterKey=') {
+ doLast {
+ def secret = project.property('secretPassword')
+ def lookupKey = project.property('lookupKey')
+ def masterKey = project.property('masterKey')
+
+ def markerName = 'SECRET'
+ def generalPropsFile = file('framework/common/config/general.properties')
+ if (generalPropsFile.exists()) {
+ def generalProps = new Properties()
+ generalPropsFile.withInputStream { generalProps.load(it) }
+ markerName = generalProps.getProperty('secret.value.marker', markerName)
+ }
+
+ def output = new java.io.ByteArrayOutputStream()
+ javaexec {
+ classpath = sourceSets.main.runtimeClasspath
+ mainClass = 'org.apache.ofbiz.base.crypto.ConfigCryptoUtil'
+ args masterKey, secret
+ standardOutput = output
+ }
+ println "systemPropertyLookup=${markerName}(${lookupKey})"
+ println "systemPropertyValue=${output.toString().trim()}"
+ }
+}
+
task generateSecretKeys(group: sysadminGroup,
description: 'Generate cryptographically secure 512-bit (64-char) secret keys for JWT token signing and password encryption, and write them to security.properties') {
doLast {
From 229d62bfb16426394b08ac67a284a7d7075c858a Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Sat, 13 Jun 2026 16:13:28 +0530
Subject: [PATCH 11/30] I renamed the gradle task and also renamed the master
key to OFBIZ_MASTER_KEY. So updating the information in Javadoc.
---
.../org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
index ed4b2dd47ee..6e819c7c51b 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
@@ -131,9 +131,10 @@ private ConfigCryptoUtil() { }
/**
* Command-line helper that prints {@code ENC()} for a given master key and plaintext
- * value, ready to be pasted into {@code passwords.properties} (e.g. as
- * {@code jdbc-password.=ENC(...)}). Invoked via the {@code encryptDbPassword}
- * Gradle task.
+ * value. Invoked via the {@code generateDBPassword} and {@code generateEncryptedSecret}
+ * Gradle tasks to produce values for {@code passwords.properties}
+ * ({@code jdbc-password.=ENC(...)}) and {@code SystemProperty.systemPropertyValue}
+ * respectively.
*/
public static void main(String[] args) throws GeneralException {
if (args.length != 2) {
From 400b9e11e4dbd089e9ce3e98386cde3b1bb1e22d Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Sun, 14 Jun 2026 22:47:00 +0530
Subject: [PATCH 12/30] Add masked stdin prompts for generateDBPassword and
generateEncryptedSecret, so secrets/master keys never need to be passed via
-P (and end up in shell history or ps). Falls back to OFBIZ_MASTER_KEY env
var when no master key is supplied; existing -P based usage is unchanged.
---
build.gradle | 68 +++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 60 insertions(+), 8 deletions(-)
diff --git a/build.gradle b/build.gradle
index 71672527c9b..69112537413 100644
--- a/build.gradle
+++ b/build.gradle
@@ -749,17 +749,63 @@ task gitInfoFooter(group: sysadminGroup, description: 'Update the Git Branch-rev
}
}
+// System.console() is unreliable under Gradle (it is almost always null, even with
+// --console=plain), so read directly from stdin instead. This still requires --no-daemon,
+// since the Gradle daemon is detached from the terminal and stdin is not forwarded to it.
+def stdinReader = new BufferedReader(new InputStreamReader(System.in))
+
+// Reads a non-sensitive value (e.g. a lookup key) from stdin.
+def promptValue = { String prompt ->
+ print prompt
+ System.out.flush()
+ def line = stdinReader.readLine()
+ if (!line) {
+ throw new GradleException("No value entered. Re-run with --no-daemon from an interactive "
+ + "terminal, or pass the value via -P.")
+ }
+ line
+}
+
+// Reads a sensitive value (password/master key) from stdin, masking the terminal echo via
+// `stty` (when a tty is available) so it never appears in shell history, `ps`, or CI logs.
+def promptSecret = { String prompt ->
+ print prompt
+ System.out.flush()
+ def ttyAvailable = new File('/dev/tty').exists()
+ if (ttyAvailable) {
+ ['sh', '-c', 'stty -echo < /dev/tty'].execute().waitFor()
+ }
+ try {
+ def line = stdinReader.readLine()
+ if (!line) {
+ throw new GradleException("No value entered. Re-run with --no-daemon from an interactive "
+ + "terminal, or pass the value via -P.")
+ }
+ return line
+ } finally {
+ if (ttyAvailable) {
+ ['sh', '-c', 'stty echo < /dev/tty'].execute().waitFor()
+ }
+ println()
+ }
+}
+
task generateDBPassword(group: sysadminGroup,
description: 'Write a plain or encrypted (ENC(...)) database password into passwords.properties. '
- + 'Usage: ./gradlew generateDBPassword -PdbPassword= -PlookupKey= [-PmasterKey=]') {
+ + 'Usage: ./gradlew generateDBPassword -PdbPassword= -PlookupKey= [-PmasterKey=]. '
+ + 'If -PdbPassword is omitted, you will be prompted for it via masked console input '
+ + '(run with --no-daemon --console=plain). The master key is taken from -PmasterKey, '
+ + 'falling back to the OFBIZ_MASTER_KEY environment variable.') {
doLast {
- def secret = project.property('dbPassword')
- if (project.hasProperty('masterKey')) {
+ def secret = project.hasProperty('dbPassword') ? project.property('dbPassword')
+ : promptSecret('Enter database password: ')
+ def masterKey = project.hasProperty('masterKey') ? project.property('masterKey') : System.getenv('OFBIZ_MASTER_KEY')
+ if (masterKey) {
def output = new java.io.ByteArrayOutputStream()
javaexec {
classpath = sourceSets.main.runtimeClasspath
mainClass = 'org.apache.ofbiz.base.crypto.ConfigCryptoUtil'
- args project.property('masterKey'), secret
+ args masterKey, secret
standardOutput = output
}
secret = output.toString().trim()
@@ -778,11 +824,17 @@ task generateDBPassword(group: sysadminGroup,
task generateEncryptedSecret(group: sysadminGroup,
description: 'Generate an encrypted secret (ENC(...)) and its systemPropertyLookup marker, for pasting '
+ 'into a SystemProperty record (e.g. payment/SMS/shipment gateway credentials). '
- + 'Usage: ./gradlew generateEncryptedSecret -PsecretPassword= -PlookupKey= -PmasterKey=') {
+ + 'Usage: ./gradlew generateEncryptedSecret -PsecretPassword= -PlookupKey= -PmasterKey=. '
+ + 'Any of -PsecretPassword/-PlookupKey/-PmasterKey may be omitted to be prompted for '
+ + 'interactively (run with --no-daemon --console=plain); the master key also falls back '
+ + 'to the OFBIZ_MASTER_KEY environment variable.') {
doLast {
- def secret = project.property('secretPassword')
- def lookupKey = project.property('lookupKey')
- def masterKey = project.property('masterKey')
+ def secret = project.hasProperty('secretPassword') ? project.property('secretPassword')
+ : promptSecret('Enter secret value: ')
+ def lookupKey = project.hasProperty('lookupKey') ? project.property('lookupKey')
+ : promptValue('Enter lookup key: ')
+ def masterKey = project.hasProperty('masterKey') ? project.property('masterKey')
+ : (System.getenv('OFBIZ_MASTER_KEY') ?: promptSecret('Enter master key: '))
def markerName = 'SECRET'
def generalPropsFile = file('framework/common/config/general.properties')
From 723ca7f804221bc4cffd79ae721c941b291e696a Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Tue, 16 Jun 2026 16:14:00 +0530
Subject: [PATCH 13/30] Adding a support of Secret Manager screen to add
encrypted secrets and passwords either in SystemProperty or
passwords.properties file. Also added the bulk secret creation option. Also
added the Sandboxing support for the entered csv file so that someone
couldn't not add malicious file.
---
.../webtools/config/WebtoolsUiLabels.xml | 77 +++++
framework/webtools/servicedef/services.xml | 12 +
.../webtools/secret/SecretManagerEvents.java | 261 +++++++++++++++++
.../secret/SecretManagerServices.java | 269 ++++++++++++++++++
framework/webtools/template/Main.ftl | 2 +
.../webtools/template/secret/EncryptValue.ftl | 150 ++++++++++
.../webapp/webtools/WEB-INF/controller.xml | 19 ++
.../webtools/widget/SecretManagerScreens.xml | 42 +++
8 files changed, 832 insertions(+)
create mode 100644 framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
create mode 100644 framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
create mode 100644 framework/webtools/template/secret/EncryptValue.ftl
create mode 100644 framework/webtools/widget/SecretManagerScreens.xml
diff --git a/framework/webtools/config/WebtoolsUiLabels.xml b/framework/webtools/config/WebtoolsUiLabels.xml
index 1df293e5edc..1dbd39703ee 100644
--- a/framework/webtools/config/WebtoolsUiLabels.xml
+++ b/framework/webtools/config/WebtoolsUiLabels.xml
@@ -2016,6 +2016,83 @@
实体XML表述資料實體XML表述
+
+ Encrypt Value
+
+
+ Secret Manager Tools
+
+
+ Encrypt a secret value with AES-256-GCM (using the server's OFBIZ_MASTER_KEY) and store
+ the result either in a SystemProperty record or in framework/base/config/passwords.properties.
+ The encrypted value is not displayed. If the target SystemProperty record or
+ passwords.properties entry already exists, it is updated with the new encrypted value;
+ otherwise it is created.
+
+
+ For SystemProperty: Resource ID and Property ID are required. Lookup Key
+ is optional.
+
+
+ For passwords.properties: Lookup Key is always required.
+ Leave Resource ID and Property ID empty to write only to passwords.properties
+ (entityengine.xml jdbc-password-lookup use-case).
+ Provide all four fields to also update the matching property file on disk
+ (sets <propertyId>=SECRET(<lookupKey>)) and refresh the in-memory
+ property cache — no server restart needed.
+
+
+ Store In
+
+
+ SystemProperty entity
+
+
+ passwords.properties
+
+
+ System Resource ID (SystemProperty)
+
+
+ System Property ID (SystemProperty)
+
+
+ Resource ID (Property File)
+
+
+ Property ID (Property File)
+
+
+ Lookup Key
+
+
+ Required for passwords.properties. When Resource ID and Property ID are empty,
+ stored as jdbc-password.<lookupKey> (entityengine.xml use-case). When all four fields are
+ provided, stored as <lookupKey> directly (property file use-case).
+ Optional for SystemProperty: if set, systemPropertyLookup is set to SECRET(<lookupKey>)
+ so a configured remote secret provider is tried first.
+
+
+ Secret Value
+
+
+ Encrypt and Save
+
+
+ Or upload a CSV file to create multiple secrets at once:
+
+
+ Columns (header row required): target, systemResourceId, systemPropertyId, lookupKey, secretValue
+
+
+ target is SYSTEM_PROPERTY or PASSWORDS_FILE; leave unused columns empty
+
+
+ CSV File
+
+
+ Upload and Encrypt
+ Entität XML ToolsEntity XML Tools
diff --git a/framework/webtools/servicedef/services.xml b/framework/webtools/servicedef/services.xml
index dcbb320b79b..1a957b02273 100644
--- a/framework/webtools/servicedef/services.xml
+++ b/framework/webtools/servicedef/services.xml
@@ -122,6 +122,18 @@ under the License.
if the user has the ENTITY_MAINT permission.
+
+ Encrypts a secret value with ConfigCryptoUtil (AES-256-GCM, keyed by the OFBIZ_MASTER_KEY
+ environment variable) and stores the resulting ENC(...) value either as a SystemProperty.systemPropertyValue
+ or as a jdbc-password.<lookupKey> entry in passwords.properties.
+
+
+
+
+
+
+ Saves service and related artifacts diagram to an Apple EOModelBundle file.
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
new file mode 100644
index 00000000000..9a2d9073108
--- /dev/null
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
@@ -0,0 +1,261 @@
+/*******************************************************************************
+ * 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.webtools.secret;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.apache.commons.csv.CSVFormat;
+import org.apache.commons.csv.CSVParser;
+import org.apache.commons.csv.CSVRecord;
+import org.apache.commons.fileupload2.core.DiskFileItem;
+import org.apache.commons.fileupload2.core.FileItem;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilGenerics;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.security.SecuredUpload;
+import org.apache.ofbiz.security.Security;
+
+/**
+ * Handles bulk creation of encrypted secrets from a CSV upload on the webtools "Encrypt Value"
+ * screen. Each row is processed by {@link SecretManagerServices#storeEncryptedSecret}, the same
+ * logic used by the single-entry form.
+ *
+ *
Expected CSV header: {@code target,systemResourceId,systemPropertyId,lookupKey,secretValue}.
+ * {@code target} is either {@code SYSTEM_PROPERTY} or {@code PASSWORDS_FILE}.
+ */
+public final class SecretManagerEvents {
+
+ private static final String MODULE = SecretManagerEvents.class.getName();
+ private static final String UPLOAD_FIELD_NAME = "uploadedFile";
+
+ private static final int MAX_FILE_SIZE_BYTES = 512 * 1024; // 512 KB
+ private static final int MAX_ROWS = 500;
+ private static final List REQUIRED_HEADERS =
+ List.of("target", "systemResourceId", "systemPropertyId", "lookupKey", "secretValue");
+ private static final Set VALID_TARGETS = Set.of(
+ SecretManagerServices.TARGET_SYSTEM_PROPERTY, SecretManagerServices.TARGET_PASSWORDS_FILE);
+ /** Allows only letters, digits, dots, hyphens, underscores — prevents path traversal and property-file injection. */
+ private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[\\w.\\-]+$");
+
+ private SecretManagerEvents() { }
+
+ public static String uploadEncryptedSecrets(HttpServletRequest request, HttpServletResponse response) {
+ Delegator delegator = (Delegator) request.getAttribute("delegator");
+ Security security = (Security) request.getAttribute("security");
+ GenericValue userLogin = (GenericValue) request.getSession().getAttribute("userLogin");
+
+ if (security == null || !security.hasPermission("ENTITY_MAINT", userLogin)) {
+ request.setAttribute("_ERROR_MESSAGE_", "You do not have permission to perform this operation");
+ return "error";
+ }
+
+ byte[] csvBytes = getUploadedFileBytes(request);
+ if (csvBytes == null || csvBytes.length == 0) {
+ request.setAttribute("_ERROR_MESSAGE_", "No CSV file was uploaded");
+ return "error";
+ }
+
+ if (csvBytes.length > MAX_FILE_SIZE_BYTES) {
+ request.setAttribute("_ERROR_MESSAGE_",
+ "CSV file exceeds the maximum allowed size of " + (MAX_FILE_SIZE_BYTES / 1024) + " KB");
+ return "error";
+ }
+
+ // Pre-scan: validate every row before writing anything — reject the entire file on any issue
+ List validationErrors;
+ try {
+ validationErrors = validateCsvContent(csvBytes);
+ } catch (IllegalArgumentException e) {
+ request.setAttribute("_ERROR_MESSAGE_", "CSV file is malformed: " + e.getMessage());
+ return "error";
+ } catch (IOException e) {
+ Debug.logError(e, MODULE);
+ request.setAttribute("_ERROR_MESSAGE_", "Error reading CSV file: " + e.getMessage());
+ return "error";
+ }
+ if (!validationErrors.isEmpty()) {
+ request.setAttribute("_ERROR_MESSAGE_LIST_", validationErrors);
+ return "error";
+ }
+
+ int successCount = 0;
+ List errors = new ArrayList<>();
+ CSVFormat format = buildCsvFormat();
+ try (Reader reader = new InputStreamReader(new ByteArrayInputStream(csvBytes), StandardCharsets.UTF_8);
+ CSVParser parser = format.parse(reader)) {
+ for (CSVRecord record : parser) {
+ long rowNum = record.getRecordNumber() + 1;
+ try {
+ SecretManagerServices.storeEncryptedSecret(delegator,
+ getColumn(record, "target"),
+ getColumn(record, "systemResourceId"),
+ getColumn(record, "systemPropertyId"),
+ getColumn(record, "lookupKey"),
+ getColumn(record, "secretValue"));
+ successCount++;
+ } catch (Exception e) {
+ errors.add("Row " + rowNum + ": " + e.getMessage());
+ }
+ }
+ } catch (IOException e) {
+ Debug.logError(e, MODULE);
+ request.setAttribute("_ERROR_MESSAGE_", "Error parsing CSV file: " + e.getMessage());
+ return "error";
+ }
+
+ request.setAttribute("_EVENT_MESSAGE_", successCount + " secret(s) encrypted and stored successfully");
+ if (!errors.isEmpty()) {
+ request.setAttribute("_ERROR_MESSAGE_LIST_", errors);
+ return "error";
+ }
+ return "success";
+ }
+
+ private static CSVFormat buildCsvFormat() {
+ return CSVFormat.DEFAULT.builder()
+ .setHeader()
+ .setSkipHeaderRecord(true)
+ .setTrim(true)
+ .setIgnoreEmptyLines(true)
+ .get();
+ }
+
+ /**
+ * Validates every row of the CSV before any writes occur. Returns a list of human-readable
+ * error messages (one per problem found). An empty list means the file is safe to process.
+ *
+ *
Checks performed:
+ *
+ *
Whole-file content scan via {@link SecuredUpload#isValidTextContent} — rejects null
+ * bytes and C0/C1 control characters at the Unicode code-point level before any parsing.
+ *
All required column headers are present.
+ *
Row count does not exceed {@link #MAX_ROWS}.
+ *
{@code target} is one of the two known constants.
+ *
{@code systemResourceId}, {@code systemPropertyId}, {@code lookupKey} contain only
+ * safe identifier characters (letters, digits, dots, hyphens, underscores) and no
+ * {@code ..} path-traversal sequences.
+ *
+ */
+ private static List validateCsvContent(byte[] csvBytes) throws IOException {
+ List errors = new ArrayList<>();
+
+ // Whole-file content scan using OFBiz's existing SecuredUpload allow-list validator.
+ // Rejects null bytes and C0/C1 control characters at the Unicode code-point level —
+ // more robust than a simple char scan because it cannot be bypassed by encoding tricks.
+ String csvText = new String(csvBytes, StandardCharsets.UTF_8);
+ if (!SecuredUpload.isValidTextContent(csvText)) {
+ errors.add("CSV file contains illegal characters (null bytes or control characters) and cannot be processed");
+ return errors;
+ }
+
+ try (Reader reader = new InputStreamReader(new ByteArrayInputStream(csvBytes), StandardCharsets.UTF_8);
+ CSVParser parser = buildCsvFormat().parse(reader)) {
+ // Required headers
+ Map headers = parser.getHeaderMap();
+ for (String col : REQUIRED_HEADERS) {
+ if (!headers.containsKey(col)) {
+ errors.add("Missing required column header: '" + col + "'");
+ }
+ }
+ if (!errors.isEmpty()) {
+ return errors; // can't validate rows without the correct headers
+ }
+ int rowCount = 0;
+ for (CSVRecord record : parser) {
+ if (++rowCount > MAX_ROWS) {
+ errors.add("File exceeds the maximum of " + MAX_ROWS + " data rows");
+ break;
+ }
+ long rowNum = record.getRecordNumber() + 1;
+ String target = getColumn(record, "target");
+ String resourceId = getColumn(record, "systemResourceId");
+ String propertyId = getColumn(record, "systemPropertyId");
+ String lookupKey = getColumn(record, "lookupKey");
+
+ // target must be a known constant
+ if (target != null && !VALID_TARGETS.contains(target)) {
+ errors.add("Row " + rowNum + ": unknown target '" + target
+ + "' — expected PASSWORDS_FILE or SYSTEM_PROPERTY");
+ }
+ // identifier fields: safe chars only + no path traversal
+ validateIdentifier(errors, rowNum, "systemResourceId", resourceId);
+ validateIdentifier(errors, rowNum, "systemPropertyId", propertyId);
+ validateIdentifier(errors, rowNum, "lookupKey", lookupKey);
+ }
+ }
+ return errors;
+ }
+
+ /** Validates that {@code value} contains only safe identifier characters and no {@code ..}. */
+ private static void validateIdentifier(List errors, long rowNum, String field, String value) {
+ if (value == null) {
+ return;
+ }
+ if (value.contains("..")) {
+ errors.add("Row " + rowNum + ": '" + field + "' must not contain '..' (path traversal)");
+ return;
+ }
+ if (!SAFE_IDENTIFIER.matcher(value).matches()) {
+ errors.add("Row " + rowNum + ": '" + field
+ + "' contains invalid characters — only letters, digits, dots, hyphens, and underscores are allowed");
+ }
+ }
+
+ private static String getColumn(CSVRecord record, String name) {
+ if (!record.isMapped(name)) {
+ return null;
+ }
+ String value = record.get(name);
+ return UtilValidate.isEmpty(value) ? null : value;
+ }
+
+ /**
+ * Returns the bytes of the uploaded {@code uploadedFile} part. The multipart body has already
+ * been parsed by {@code ControlFilter} (via {@code UtilHttp.getParameterMap}), which stashes the
+ * parsed {@link FileItem}s in the {@code fileItems} request attribute - the underlying request
+ * input stream can not be re-parsed here.
+ */
+ private static byte[] getUploadedFileBytes(HttpServletRequest request) {
+ List> items = UtilGenerics.cast(request.getAttribute("fileItems"));
+ if (items == null) {
+ return null;
+ }
+ for (FileItem item : items) {
+ if (!item.isFormField() && UPLOAD_FIELD_NAME.equals(item.getFieldName())) {
+ return item.get();
+ }
+ }
+ return null;
+ }
+}
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
new file mode 100644
index 00000000000..73681a7a8e2
--- /dev/null
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
@@ -0,0 +1,269 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.webtools.secret;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.ofbiz.base.component.ComponentConfig;
+import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
+import org.apache.ofbiz.base.location.FlexibleLocation;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.base.util.cache.UtilCache;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.util.EntityQuery;
+import org.apache.ofbiz.service.DispatchContext;
+import org.apache.ofbiz.service.ServiceUtil;
+
+/**
+ * Encrypts secret/password values with {@link ConfigCryptoUtil} (AES-256-GCM, keyed by the
+ * {@code OFBIZ_MASTER_KEY} environment variable) and stores the resulting {@code ENC(...)} value
+ * either in a {@code SystemProperty} record or as a {@code jdbc-password.} entry in
+ * {@code framework/base/config/passwords.properties}.
+ */
+public final class SecretManagerServices {
+
+ private static final String MODULE = SecretManagerServices.class.getName();
+
+ public static final String TARGET_SYSTEM_PROPERTY = "SYSTEM_PROPERTY";
+ public static final String TARGET_PASSWORDS_FILE = "PASSWORDS_FILE";
+
+ private static final String PASSWORDS_FILE_LOCATION = "component://base/config/passwords.properties";
+ private static final String JDBC_PASSWORD_PREFIX = "jdbc-password.";
+
+ private SecretManagerServices() { }
+
+ /** Service implementation for the webtools "Encrypt Value" screen. */
+ public static Map createEncryptedSecret(DispatchContext dctx, Map context) {
+ Delegator delegator = dctx.getDelegator();
+ String secretTarget = (String) context.get("secretTarget");
+ String systemResourceId = (String) context.get("systemResourceId");
+ String systemPropertyId = (String) context.get("systemPropertyId");
+ String lookupKey = (String) context.get("lookupKey");
+ String secretValue = (String) context.get("secretValue");
+
+ try {
+ storeEncryptedSecret(delegator, secretTarget, systemResourceId, systemPropertyId, lookupKey, secretValue);
+ } catch (GeneralException e) {
+ Debug.logError(e, MODULE);
+ return ServiceUtil.returnError(e.getMessage());
+ }
+ return ServiceUtil.returnSuccess("Secret value encrypted and stored successfully");
+ }
+
+ /**
+ * Encrypts {@code secretValue} and stores it as configured by {@code secretTarget}. Used by
+ * both the single-entry service ({@link #createEncryptedSecret}) and the CSV bulk-upload
+ * event so that both paths share the exact same validation and storage logic.
+ */
+ public static void storeEncryptedSecret(Delegator delegator, String secretTarget, String systemResourceId,
+ String systemPropertyId, String lookupKey, String secretValue) throws GeneralException {
+ if (UtilValidate.isEmpty(secretValue)) {
+ throw new GeneralException("secretValue is required");
+ }
+ String encryptedValue = "ENC(" + ConfigCryptoUtil.encrypt(secretValue, getMasterKey()) + ")";
+
+ if (TARGET_PASSWORDS_FILE.equals(secretTarget)) {
+ if (UtilValidate.isEmpty(lookupKey)) {
+ throw new GeneralException("lookupKey is required for passwords.properties");
+ }
+ boolean hasResourceId = UtilValidate.isNotEmpty(systemResourceId);
+ boolean hasPropertyId = UtilValidate.isNotEmpty(systemPropertyId);
+ if (hasResourceId != hasPropertyId) {
+ throw new GeneralException(
+ "systemResourceId and systemPropertyId must both be provided together");
+ }
+ if (hasResourceId) {
+ // Case B: property-file combined write — use plain lookupKey, no jdbc-password. prefix
+ writePasswordsProperty(lookupKey, encryptedValue);
+ updatePropertiesFileAndRefreshCache(systemResourceId, systemPropertyId, lookupKey);
+ } else {
+ // Case A: entityengine.xml jdbc-password-lookup — keep the jdbc-password. prefix
+ writePasswordsProperty(JDBC_PASSWORD_PREFIX + lookupKey, encryptedValue);
+ }
+ } else if (TARGET_SYSTEM_PROPERTY.equals(secretTarget)) {
+ if (UtilValidate.isEmpty(systemResourceId) || UtilValidate.isEmpty(systemPropertyId)) {
+ throw new GeneralException("systemResourceId and systemPropertyId are required for SystemProperty");
+ }
+ storeSystemPropertySecret(delegator, systemResourceId, systemPropertyId, lookupKey, encryptedValue);
+ } else {
+ throw new GeneralException("Unknown secretTarget '" + secretTarget + "'");
+ }
+ }
+
+ private static void storeSystemPropertySecret(Delegator delegator, String systemResourceId, String systemPropertyId,
+ String lookupKey, String encryptedValue) throws GeneralException {
+ GenericValue systemProperty = EntityQuery.use(delegator).from("SystemProperty")
+ .where("systemResourceId", systemResourceId, "systemPropertyId", systemPropertyId)
+ .queryOne();
+ if (systemProperty == null) {
+ systemProperty = delegator.makeValue("SystemProperty",
+ "systemResourceId", systemResourceId, "systemPropertyId", systemPropertyId);
+ }
+ systemProperty.set("systemPropertyValue", encryptedValue);
+ if (UtilValidate.isNotEmpty(lookupKey)) {
+ systemProperty.set("systemPropertyLookup", "SECRET(" + lookupKey + ")");
+ }
+ delegator.createOrStore(systemProperty);
+ }
+
+ /**
+ * Searches all loaded OFBiz components for {@code config/.properties},
+ * sets {@code =SECRET()} in the source file, then clears the
+ * OFBiz property caches so the change is picked up immediately without a server restart.
+ *
+ *
If the property previously referenced a different lookup key via {@code SECRET(old-key)},
+ * the stale {@code old-key} entry is removed from passwords.properties before the new one is written.
+ *
+ *
Scanning component directories (not the classpath) ensures we write to the actual
+ * source {@code .properties} file rather than the build-output copy.
+ */
+ private static void updatePropertiesFileAndRefreshCache(String systemResourceId,
+ String systemPropertyId, String lookupKey) throws GeneralException {
+ File propsFile = findSourcePropertiesFile(systemResourceId);
+ if (propsFile == null) {
+ throw new GeneralException(
+ "Properties file not found for resource: " + systemResourceId);
+ }
+ // If the property already points to a different lookup key, remove the stale passwords.properties entry
+ String existingLookupKey = readSecretLookupKey(propsFile, systemPropertyId);
+ if (existingLookupKey != null && !existingLookupKey.equals(lookupKey)) {
+ removePasswordsEntry(existingLookupKey);
+ Debug.logInfo("Removed stale passwords.properties entry for old lookup key: " + existingLookupKey, MODULE);
+ }
+ writePropertiesEntry(propsFile, systemPropertyId, "SECRET(" + lookupKey + ")");
+ UtilCache.clearCachesThatStartWith("properties.UtilProperties");
+ Debug.logInfo("Set " + systemPropertyId + "=SECRET(" + lookupKey + ") in "
+ + propsFile.getPath() + " and refreshed property caches", MODULE);
+ }
+
+ /**
+ * Reads {@code file} and returns the key inside {@code SECRET()} for the given
+ * {@code propertyId}, or {@code null} if the property is absent or not a SECRET reference.
+ */
+ private static String readSecretLookupKey(File file, String propertyId) throws GeneralException {
+ try {
+ String linePrefix = propertyId + "=";
+ for (String line : Files.readAllLines(file.toPath(), StandardCharsets.UTF_8)) {
+ if (line.startsWith(linePrefix)) {
+ String value = line.substring(linePrefix.length()).trim();
+ if (value.startsWith("SECRET(") && value.endsWith(")")) {
+ return value.substring("SECRET(".length(), value.length() - 1);
+ }
+ return null;
+ }
+ }
+ return null;
+ } catch (IOException e) {
+ throw new GeneralException("Unable to read " + file.getName() + ": " + e.getMessage(), e);
+ }
+ }
+
+ /** Removes the line for {@code key} from passwords.properties (no-op if not present). */
+ private static void removePasswordsEntry(String key) throws GeneralException {
+ removePropertiesEntry(getPasswordsFile(), key);
+ }
+
+ /** Removes the {@code key=...} line from {@code file} (no-op if not present). */
+ private static synchronized void removePropertiesEntry(File file, String key) throws GeneralException {
+ try {
+ List lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
+ String linePrefix = key + "=";
+ if (lines.removeIf(line -> line.startsWith(linePrefix))) {
+ Files.write(file.toPath(), lines, StandardCharsets.UTF_8);
+ }
+ } catch (IOException e) {
+ throw new GeneralException("Unable to update " + file.getName() + ": " + e.getMessage(), e);
+ }
+ }
+
+ /** Scans all loaded component {@code config/} directories for {@code .properties}. */
+ private static File findSourcePropertiesFile(String systemResourceId) {
+ String fileName = systemResourceId + ".properties";
+ for (ComponentConfig cc : ComponentConfig.getAllComponents()) {
+ Path candidate = cc.rootLocation().resolve("config").resolve(fileName);
+ if (Files.isRegularFile(candidate)) {
+ return candidate.toFile();
+ }
+ }
+ return null;
+ }
+
+ /** Updates (or appends) {@code key=value} in an arbitrary properties file, preserving all other lines. */
+ private static synchronized void writePropertiesEntry(File file, String key, String value)
+ throws GeneralException {
+ try {
+ List lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
+ String newLine = key + "=" + value;
+ String linePrefix = key + "=";
+ int index = -1;
+ for (int i = 0; i < lines.size(); i++) {
+ if (lines.get(i).startsWith(linePrefix)) {
+ index = i;
+ break;
+ }
+ }
+ if (index >= 0) {
+ lines.set(index, newLine);
+ } else {
+ lines.add(newLine);
+ }
+ Files.write(file.toPath(), lines, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new GeneralException(
+ "Unable to update " + file.getName() + ": " + e.getMessage(), e);
+ }
+ }
+
+ /** Updates (or appends) {@code propertyName=value} in passwords.properties, preserving all other lines. */
+ private static void writePasswordsProperty(String propertyName, String value) throws GeneralException {
+ writePropertiesEntry(getPasswordsFile(), propertyName, value);
+ }
+
+ private static File getPasswordsFile() throws GeneralException {
+ try {
+ URL url = FlexibleLocation.resolveLocation(PASSWORDS_FILE_LOCATION);
+ if (url == null) {
+ throw new GeneralException("Unable to locate passwords.properties");
+ }
+ return new File(url.toURI());
+ } catch (MalformedURLException | URISyntaxException e) {
+ throw new GeneralException("Unable to locate passwords.properties: " + e.getMessage(), e);
+ }
+ }
+
+ private static String getMasterKey() throws GeneralException {
+ String masterKey = System.getenv("OFBIZ_MASTER_KEY");
+ if (UtilValidate.isEmpty(masterKey)) {
+ throw new GeneralException("The OFBIZ_MASTER_KEY environment variable is not set on the server");
+ }
+ return masterKey;
+ }
+}
diff --git a/framework/webtools/template/Main.ftl b/framework/webtools/template/Main.ftl
index 3fe4b097802..9b81727adb9 100644
--- a/framework/webtools/template/Main.ftl
+++ b/framework/webtools/template/Main.ftl
@@ -80,6 +80,8 @@ under the License.
diff --git a/framework/webtools/template/secret/EncryptValue.ftl b/framework/webtools/template/secret/EncryptValue.ftl
new file mode 100644
index 00000000000..c2754db8d20
--- /dev/null
+++ b/framework/webtools/template/secret/EncryptValue.ftl
@@ -0,0 +1,150 @@
+<#--
+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.
+-->
+
+
This allows any property read through {@code UtilProperties} or
@@ -38,14 +38,14 @@
* same {@link SecretProvider} SPI and providers already used for
* {@code jdbc-password-lookup}.
*
- *
Values that do not match {@code SECRET(key)} are returned unchanged.
+ *
Values that do not match {@code LOOKUP(key)} are returned unchanged.
* The {@code jdbc-password-lookup} mechanism in {@code entityengine.xml}
* ({@code EntityConfig.getJdbcPassword()}) does not go through this class and
* is unaffected.
*
- *
The marker name ({@code SECRET} by default) can be changed via the
- * {@code secret.value.marker} property in {@code general.properties}, e.g. to
- * {@code MASK} or {@code ENCRYPT}, in case it collides with existing property
+ *
The marker name ({@code LOOKUP} by default) can be changed via the
+ * {@code secret.value.marker} property in {@code security.properties}, e.g. to
+ * {@code SECRET} or {@code ENCRYPT}, in case it collides with existing property
* values.
*
*
Resolved values are cached for {@code secret.cache.ttl.seconds}
@@ -59,17 +59,17 @@ public final class SecretValueResolver {
// Read the marker name and cache TTL directly from the Properties object, bypassing
// UtilProperties.getPropertyValue(), which calls back into resolve() and would otherwise
// deadlock/NPE on these fields during static initialization.
- private static final Properties GENERAL_PROPERTIES = UtilProperties.getProperties("general");
+ private static final Properties SECURITY_PROPERTIES = UtilProperties.getProperties("security");
- private static final String MARKER_NAME = GENERAL_PROPERTIES != null
- ? GENERAL_PROPERTIES.getProperty("secret.value.marker", "SECRET").trim()
- : "SECRET";
+ public static final String MARKER_NAME = SECURITY_PROPERTIES != null
+ ? SECURITY_PROPERTIES.getProperty("secret.value.marker", "LOOKUP").trim()
+ : "LOOKUP";
private static final Pattern SECRET_PATTERN =
Pattern.compile("^" + Pattern.quote(MARKER_NAME) + "\\((.+)\\)$");
- private static final long CACHE_TTL_MILLIS = (GENERAL_PROPERTIES != null
- ? Long.parseLong(GENERAL_PROPERTIES.getProperty("secret.cache.ttl.seconds", "300").trim())
+ private static final long CACHE_TTL_MILLIS = (SECURITY_PROPERTIES != null
+ ? Long.parseLong(SECURITY_PROPERTIES.getProperty("secret.cache.ttl.seconds", "300").trim())
: 300L) * 1000L;
private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>();
@@ -77,13 +77,13 @@ public final class SecretValueResolver {
private SecretValueResolver() { }
/**
- * If {@code rawValue} matches {@code SECRET(key)}, resolves {@code key} via
+ * If {@code rawValue} matches {@code LOOKUP(key)}, resolves {@code key} via
* {@link SecretProviderFactory#getInstance()}, caching the result for
* {@code secret.cache.ttl.seconds}. Otherwise returns {@code rawValue} unchanged.
*
* @param rawValue the raw property value, possibly {@code null}
* @return the resolved secret value, or {@code rawValue} unchanged if it is not a
- * {@code SECRET(key)} reference; an empty string if resolution fails
+ * {@code LOOKUP(key)} reference; an empty string if resolution fails
*/
public static String resolve(String rawValue) {
if (rawValue == null) {
@@ -93,13 +93,28 @@ public static String resolve(String rawValue) {
if (!matcher.matches()) {
return rawValue;
}
- String key = matcher.group(1).trim();
+ return resolveKey(matcher.group(1).trim());
+ }
+ /**
+ * Resolves {@code key} directly via {@link SecretProviderFactory}, with the same TTL-based
+ * caching as {@link #resolve(String)}. Use this when the caller already holds the raw key
+ * (e.g. from {@code SystemProperty.systemPropertyLookup}) without a {@code LOOKUP(...)} wrapper.
+ *
+ * @param key the raw secret key, possibly {@code null} or empty
+ * @return the resolved secret value; an empty string if resolution fails; {@code null} if {@code key} is {@code null}
+ */
+ public static String resolveKey(String key) {
+ if (key == null) {
+ return null;
+ }
+ if (key.isEmpty()) {
+ return "";
+ }
CacheEntry cached = CACHE.get(key);
if (cached != null && cached.expiry > System.currentTimeMillis()) {
return cached.value;
}
-
try {
String secret = SecretProviderFactory.getInstance().getSecret(key);
CACHE.put(key, new CacheEntry(secret, System.currentTimeMillis() + CACHE_TTL_MILLIS));
diff --git a/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java
index ab9e3d95776..21bba1a29b1 100644
--- a/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java
+++ b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java
@@ -45,13 +45,13 @@ public void nullIsReturnedUnchanged() {
@Test
public void resolvesKnownKeyFromPasswordsProperties() {
- assertEquals("ofbiz", SecretValueResolver.resolve("SECRET(jdbc-password.h2-ofbiz)"));
+ assertEquals("ofbiz", SecretValueResolver.resolve("LOOKUP(jdbc-password.h2-ofbiz)"));
// Cached lookup should return the same value.
- assertEquals("ofbiz", SecretValueResolver.resolve("SECRET(jdbc-password.h2-ofbiz)"));
+ assertEquals("ofbiz", SecretValueResolver.resolve("LOOKUP(jdbc-password.h2-ofbiz)"));
}
@Test
public void unresolvableKeyReturnsEmptyString() {
- assertEquals("", SecretValueResolver.resolve("SECRET(jdbc-password.does-not-exist)"));
+ assertEquals("", SecretValueResolver.resolve("LOOKUP(jdbc-password.does-not-exist)"));
}
}
diff --git a/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtilProperties.java b/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtilProperties.java
index 11a9b491a3c..56cc9c94d89 100644
--- a/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtilProperties.java
+++ b/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtilProperties.java
@@ -83,11 +83,10 @@ private static Map getSystemPropertyValue(String resource, Strin
/**
* Resolves the effective value of a {@code SystemProperty} record:
*
- *
if {@code systemPropertyLookup} holds a {@code SECRET(key)} reference (see
- * {@link SecretValueResolver}), resolves {@code key} via the configured
- * {@code SecretProvider} (a remote secret manager, with its own
- * {@code passwords.properties} fallback); if that fails, falls back to
- * {@code systemPropertyValue}
+ *
if {@code systemPropertyLookup} is non-empty, its value is treated as a raw secret
+ * key and resolved directly via {@link SecretValueResolver#resolveKey(String)} (i.e.
+ * no {@code LOOKUP(...)} wrapper — the field itself implies lookup semantics); if
+ * resolution fails or returns empty, falls back to {@code systemPropertyValue}
*
otherwise (or on lookup failure), uses {@code systemPropertyValue}, decrypting it
* with {@link ConfigCryptoUtil} if it is wrapped in {@code ENC(...)}
*
@@ -96,12 +95,12 @@ private static String resolveSystemPropertyValue(String resource, String name, G
String lookup = systemProperty.getString("systemPropertyLookup");
if (UtilValidate.isNotEmpty(lookup)) {
Debug.logInfo("EntityUtilProperties: resolving " + resource + "/" + name
- + " via SystemProperty.systemPropertyLookup '" + lookup + "'", MODULE);
- String resolved = SecretValueResolver.resolve(lookup);
- if (UtilValidate.isNotEmpty(resolved) && !resolved.equals(lookup)) {
+ + " via SystemProperty.systemPropertyLookup key '" + lookup + "'", MODULE);
+ String resolved = SecretValueResolver.resolveKey(lookup);
+ if (UtilValidate.isNotEmpty(resolved)) {
return resolved;
}
- Debug.logWarning("EntityUtilProperties: systemPropertyLookup '" + lookup + "' for " + resource + "/" + name
+ Debug.logWarning("EntityUtilProperties: systemPropertyLookup key '" + lookup + "' for " + resource + "/" + name
+ " did not resolve, falling back to systemPropertyValue", MODULE);
}
String value = systemProperty.getString("systemPropertyValue");
diff --git a/framework/security/config/security.properties b/framework/security/config/security.properties
index 088a4399b4c..0f2963e325a 100644
--- a/framework/security/config/security.properties
+++ b/framework/security/config/security.properties
@@ -421,3 +421,12 @@ path.shortener.size=10
#-- Use this in combination with freemarker-whitelist.properties to restrict static method calls in freemarker templates.
#-- If set to false, no static method calls are filtered.
freemarker.use-restricted-static-models=true
+
+# -- Secret Manager settings
+# -- Marker used to identify lookup references in property values and SystemProperty.systemPropertyLookup fields.
+# -- Change this only if 'LOOKUP' collides with an existing property value in your installation.
+# -- Supported alternatives: SECRET, ENCRYPT or any uppercase word without parentheses.
+secret.value.marker=LOOKUP
+# -- How long (in seconds) to cache resolved secret values before re-fetching from the provider.
+# -- Increase for high-throughput installs; decrease for environments with frequent secret rotation.
+secret.cache.ttl.seconds=300
diff --git a/framework/webtools/config/WebtoolsUiLabels.xml b/framework/webtools/config/WebtoolsUiLabels.xml
index 1dbd39703ee..9eff2e0c5de 100644
--- a/framework/webtools/config/WebtoolsUiLabels.xml
+++ b/framework/webtools/config/WebtoolsUiLabels.xml
@@ -2038,7 +2038,7 @@
Leave Resource ID and Property ID empty to write only to passwords.properties
(entityengine.xml jdbc-password-lookup use-case).
Provide all four fields to also update the matching property file on disk
- (sets <propertyId>=SECRET(<lookupKey>)) and refresh the in-memory
+ (sets <propertyId>=LOOKUP(<lookupKey>)) and refresh the in-memory
property cache — no server restart needed.
@@ -2069,7 +2069,7 @@
Required for passwords.properties. When Resource ID and Property ID are empty,
stored as jdbc-password.<lookupKey> (entityengine.xml use-case). When all four fields are
provided, stored as <lookupKey> directly (property file use-case).
- Optional for SystemProperty: if set, systemPropertyLookup is set to SECRET(<lookupKey>)
+ Optional for SystemProperty: if set, the plain key is stored in systemPropertyLookup
so a configured remote secret provider is tried first.
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
index 9a2d9073108..2c08c4c702e 100644
--- a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
@@ -202,6 +202,7 @@ private static List validateCsvContent(byte[] csvBytes) throws IOExcepti
String resourceId = getColumn(record, "systemResourceId");
String propertyId = getColumn(record, "systemPropertyId");
String lookupKey = getColumn(record, "lookupKey");
+ String secretValue = getColumn(record, "secretValue");
// target must be a known constant
if (target != null && !VALID_TARGETS.contains(target)) {
@@ -212,6 +213,11 @@ private static List validateCsvContent(byte[] csvBytes) throws IOExcepti
validateIdentifier(errors, rowNum, "systemResourceId", resourceId);
validateIdentifier(errors, rowNum, "systemPropertyId", propertyId);
validateIdentifier(errors, rowNum, "lookupKey", lookupKey);
+ // secretValue must be the plain secret, not an already-encrypted value
+ if (secretValue != null && secretValue.trim().startsWith("ENC(")) {
+ errors.add("Row " + rowNum + ": 'secretValue' must be the plain secret"
+ + " — do not enter an ENC(...) encrypted value");
+ }
}
}
return errors;
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
index 73681a7a8e2..8dc3ebaa8fe 100644
--- a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
@@ -28,9 +28,11 @@
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
+import java.util.regex.Pattern;
import org.apache.ofbiz.base.component.ComponentConfig;
import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
+import org.apache.ofbiz.base.secret.SecretValueResolver;
import org.apache.ofbiz.base.location.FlexibleLocation;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
@@ -57,6 +59,8 @@ public final class SecretManagerServices {
private static final String PASSWORDS_FILE_LOCATION = "component://base/config/passwords.properties";
private static final String JDBC_PASSWORD_PREFIX = "jdbc-password.";
+ /** Allows only letters, digits, dots, hyphens, underscores — blocks marker wrappers and path traversal. */
+ private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[\\w.\\-]+$");
private SecretManagerServices() { }
@@ -88,6 +92,13 @@ public static void storeEncryptedSecret(Delegator delegator, String secretTarget
if (UtilValidate.isEmpty(secretValue)) {
throw new GeneralException("secretValue is required");
}
+ if (secretValue.trim().startsWith("ENC(")) {
+ throw new GeneralException("secretValue must be the plain secret — do not enter an ENC(...) encrypted value");
+ }
+ if (UtilValidate.isNotEmpty(lookupKey) && !SAFE_IDENTIFIER.matcher(lookupKey.trim()).matches()) {
+ throw new GeneralException("lookupKey must contain only letters, digits, dots, hyphens, and underscores"
+ + " — do not enter a " + SecretValueResolver.MARKER_NAME + "(...) marker or path separator");
+ }
String encryptedValue = "ENC(" + ConfigCryptoUtil.encrypt(secretValue, getMasterKey()) + ")";
if (TARGET_PASSWORDS_FILE.equals(secretTarget)) {
@@ -129,17 +140,18 @@ private static void storeSystemPropertySecret(Delegator delegator, String system
}
systemProperty.set("systemPropertyValue", encryptedValue);
if (UtilValidate.isNotEmpty(lookupKey)) {
- systemProperty.set("systemPropertyLookup", "SECRET(" + lookupKey + ")");
+ systemProperty.set("systemPropertyLookup", lookupKey);
}
delegator.createOrStore(systemProperty);
}
/**
* Searches all loaded OFBiz components for {@code config/.properties},
- * sets {@code =SECRET()} in the source file, then clears the
- * OFBiz property caches so the change is picked up immediately without a server restart.
+ * sets {@code =LOOKUP()} in the source {@code .properties} file
+ * (so that {@link org.apache.ofbiz.base.secret.SecretValueResolver} resolves it at runtime),
+ * then clears the OFBiz property caches so the change is picked up immediately without a restart.
*
- *
If the property previously referenced a different lookup key via {@code SECRET(old-key)},
+ *
If the property previously referenced a different lookup key via {@code LOOKUP(old-key)},
* the stale {@code old-key} entry is removed from passwords.properties before the new one is written.
*
*
Scanning component directories (not the classpath) ensures we write to the actual
@@ -158,15 +170,17 @@ private static void updatePropertiesFileAndRefreshCache(String systemResourceId,
removePasswordsEntry(existingLookupKey);
Debug.logInfo("Removed stale passwords.properties entry for old lookup key: " + existingLookupKey, MODULE);
}
- writePropertiesEntry(propsFile, systemPropertyId, "SECRET(" + lookupKey + ")");
+ writePropertiesEntry(propsFile, systemPropertyId, SecretValueResolver.MARKER_NAME + "(" + lookupKey + ")");
UtilCache.clearCachesThatStartWith("properties.UtilProperties");
- Debug.logInfo("Set " + systemPropertyId + "=SECRET(" + lookupKey + ") in "
+ Debug.logInfo("Set " + systemPropertyId + "=" + SecretValueResolver.MARKER_NAME + "(" + lookupKey + ") in "
+ propsFile.getPath() + " and refreshed property caches", MODULE);
}
/**
- * Reads {@code file} and returns the key inside {@code SECRET()} for the given
- * {@code propertyId}, or {@code null} if the property is absent or not a SECRET reference.
+ * Reads {@code file} and returns the key inside a {@code LOOKUP()} marker for the given
+ * {@code propertyId}, or {@code null} if the property is absent or not a LOOKUP reference.
+ * Used only for the source {@code .properties} file path (Case B); {@code SystemProperty.systemPropertyLookup}
+ * stores the raw key without a wrapper.
*/
private static String readSecretLookupKey(File file, String propertyId) throws GeneralException {
try {
@@ -174,8 +188,9 @@ private static String readSecretLookupKey(File file, String propertyId) throws G
for (String line : Files.readAllLines(file.toPath(), StandardCharsets.UTF_8)) {
if (line.startsWith(linePrefix)) {
String value = line.substring(linePrefix.length()).trim();
- if (value.startsWith("SECRET(") && value.endsWith(")")) {
- return value.substring("SECRET(".length(), value.length() - 1);
+ String markerPrefix = SecretValueResolver.MARKER_NAME + "(";
+ if (value.startsWith(markerPrefix) && value.endsWith(")")) {
+ return value.substring(markerPrefix.length(), value.length() - 1);
}
return null;
}
diff --git a/framework/webtools/template/secret/EncryptValue.ftl b/framework/webtools/template/secret/EncryptValue.ftl
index c2754db8d20..64b99e9e7d6 100644
--- a/framework/webtools/template/secret/EncryptValue.ftl
+++ b/framework/webtools/template/secret/EncryptValue.ftl
@@ -117,6 +117,42 @@ under the License.
r.addEventListener('change', updateLabels);
});
updateLabels();
+
+ // Client-side validation: reject marker wrappers and pre-encrypted values before submit
+ var SAFE_ID = /^[\w.\-]+$/;
+ var form = document.querySelector('form[action$="createEncryptedSecret"]');
+ var errDiv = document.createElement('div');
+ errDiv.style.cssText = 'display:none;padding:8px;margin-bottom:8px;border:1px solid #a00;background:#fff0f0;color:#700;';
+ form.parentNode.insertBefore(errDiv, form);
+
+ form.addEventListener('submit', function (e) {
+ var errors = [];
+ var lookupKeyInput = form.querySelector('input[name="lookupKey"]');
+ var secretValueInput = form.querySelector('input[name="secretValue"]');
+ var lookupKey = lookupKeyInput.value.trim();
+ var secretValue = secretValueInput.value;
+
+ if (lookupKey && !SAFE_ID.test(lookupKey)) {
+ errors.push('Lookup Key must contain only letters, digits, dots, hyphens, and underscores. Do not enter a LOOKUP(...) or similar marker — type the key name only.');
+ lookupKeyInput.style.borderColor = '#a00';
+ } else {
+ lookupKeyInput.style.borderColor = '';
+ }
+ if (secretValue.trim().startsWith('ENC(')) {
+ errors.push('Secret Value must be the plain secret. Do not paste an ENC(...) encrypted value — type or paste the original password.');
+ secretValueInput.style.borderColor = '#a00';
+ } else {
+ secretValueInput.style.borderColor = '';
+ }
+
+ if (errors.length > 0) {
+ errDiv.innerHTML = errors.map(function (m) { return '
' + m + '
'; }).join('');
+ errDiv.style.display = 'block';
+ e.preventDefault();
+ } else {
+ errDiv.style.display = 'none';
+ }
+ });
}());
From dfcf653a62435f1e075dac0c4eda37eaccb44479 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 17 Jun 2026 09:00:37 +0530
Subject: [PATCH 15/30] 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.
---
.../ofbiz/base/crypto/ConfigCryptoUtil.java | 39 ++-
.../base/secret/FallbackSecretProvider.java | 115 +++++--
.../ofbiz/base/secret/SecretProvider.java | 43 ++-
.../base/secret/SecretProviderFactory.java | 140 ++++++++-
.../base/secret/SecretValueResolver.java | 195 +++++++++++-
.../base/crypto/ConfigCryptoUtilTest.java | 116 +++++++
.../secret/FallbackSecretProviderTest.java | 147 +++++++++
.../secret/FileBasedSecretProviderTest.java | 69 +++++
.../secret/SecretProviderFactoryTest.java | 62 ++++
.../base/secret/SecretValueResolverTest.java | 127 +++++++-
framework/common/entitydef/entitymodel.xml | 1 +
framework/security/config/security.properties | 20 ++
.../webtools/config/WebtoolsUiLabels.xml | 78 +++++
.../WebtoolsSecurityPermissionSeedData.xml | 4 +
framework/webtools/servicedef/services.xml | 54 +++-
.../ofbiz/webtools/WebToolsServices.java | 20 ++
.../webtools/secret/SecretManagerEvents.java | 19 +-
.../secret/SecretManagerServices.java | 282 +++++++++++++++++-
.../secret/SecretManagerEventsTest.java | 120 ++++++++
.../webtools/template/secret/EncryptValue.ftl | 173 ++++++++++-
.../webapp/webtools/WEB-INF/controller.xml | 36 +++
framework/webtools/widget/Menus.xml | 6 +
.../webtools/widget/SecretManagerScreens.xml | 13 +
23 files changed, 1802 insertions(+), 77 deletions(-)
create mode 100644 framework/base/src/test/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtilTest.java
create mode 100644 framework/base/src/test/java/org/apache/ofbiz/base/secret/FallbackSecretProviderTest.java
create mode 100644 framework/base/src/test/java/org/apache/ofbiz/base/secret/FileBasedSecretProviderTest.java
create mode 100644 framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretProviderFactoryTest.java
create mode 100644 framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretManagerEventsTest.java
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
index 6e819c7c51b..a47199d5501 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java
@@ -21,11 +21,13 @@
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Base64;
+import java.util.Properties;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
import org.apache.shiro.crypto.CryptoException;
import org.apache.shiro.crypto.cipher.AesCipherService;
import org.apache.shiro.lang.util.ByteSource;
@@ -47,7 +49,6 @@ public final class ConfigCryptoUtil {
// Static salt: the master key is the actual secret; the salt only needs to defeat
// precomputed rainbow tables, not provide per-installation uniqueness.
private static final byte[] SALT = "OFBizConfigCryptoUtilSalt".getBytes(StandardCharsets.UTF_8);
- private static final int ITERATIONS = 10000;
private static final int KEY_LENGTH_BITS = 256;
// Note: AesCipherService derives the GCM tag length from getKeySize(), which must stay at
@@ -55,6 +56,32 @@ public final class ConfigCryptoUtil {
// to encrypt/decrypt and does not depend on this setting.
private static final AesCipherService CIPHER_SERVICE = new AesCipherService();
+ // Read config once at class-load time using raw Properties to avoid re-entrancy via
+ // UtilProperties.getPropertyValue() → SecretValueResolver → ConfigCryptoUtil.
+ private static final Properties SECURITY_PROPERTIES = UtilProperties.getProperties("security");
+
+ /** Name of the environment variable holding the AES master key; configurable via secret.master.key.env.var. */
+ public static final String MASTER_KEY_ENV_VAR = SECURITY_PROPERTIES != null
+ ? SECURITY_PROPERTIES.getProperty("secret.master.key.env.var", "OFBIZ_MASTER_KEY").trim()
+ : "OFBIZ_MASTER_KEY";
+
+ // PBKDF2 iteration count. Raising this requires re-encrypting all existing ENC(...) values
+ // because the derived AES key changes when the iteration count changes.
+ static final int ITERATIONS = readIterations();
+
+ private static int readIterations() {
+ if (SECURITY_PROPERTIES == null) {
+ return 310000;
+ }
+ try {
+ int v = Integer.parseInt(
+ SECURITY_PROPERTIES.getProperty("secret.pbkdf2.iterations", "310000").trim());
+ return v > 0 ? v : 310000;
+ } catch (NumberFormatException e) {
+ return 310000;
+ }
+ }
+
private static byte[] deriveKey(String masterKey) throws GeneralSecurityException {
SecretKeyFactory factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM);
PBEKeySpec spec = new PBEKeySpec(masterKey.toCharArray(), SALT, ITERATIONS, KEY_LENGTH_BITS);
@@ -70,6 +97,9 @@ private static byte[] deriveKey(String masterKey) throws GeneralSecurityExceptio
* @return Base64 string containing the random IV followed by the ciphertext (with GCM auth tag)
*/
public static String encrypt(String plainText, String masterKey) throws GeneralException {
+ if (masterKey == null || masterKey.isEmpty()) {
+ throw new GeneralException("masterKey must not be null or empty");
+ }
try {
byte[] key = deriveKey(masterKey);
ByteSource encrypted = CIPHER_SERVICE.encrypt(plainText.getBytes(StandardCharsets.UTF_8), key);
@@ -84,6 +114,9 @@ public static String encrypt(String plainText, String masterKey) throws GeneralE
* and lets {@link AesCipherService} split the IV from the ciphertext and decrypt.
*/
public static String decrypt(String encryptedBase64, String masterKey) throws GeneralException {
+ if (masterKey == null || masterKey.isEmpty()) {
+ throw new GeneralException("masterKey must not be null or empty");
+ }
try {
byte[] key = deriveKey(masterKey);
byte[] payload = Base64.getDecoder().decode(encryptedBase64);
@@ -113,10 +146,10 @@ public static String decryptIfEncrypted(String rawValue, String secretName) thro
if (!rawValue.startsWith("ENC(") || !rawValue.endsWith(")")) {
return rawValue;
}
- String masterKey = System.getenv("OFBIZ_MASTER_KEY");
+ String masterKey = System.getenv(MASTER_KEY_ENV_VAR);
if (masterKey == null || masterKey.isEmpty()) {
throw new GeneralException("Secret '" + secretName + "' is encrypted (ENC(...)) but the "
- + "OFBIZ_MASTER_KEY environment variable is not set");
+ + MASTER_KEY_ENV_VAR + " environment variable is not set");
}
String base64 = rawValue.substring("ENC(".length(), rawValue.length() - 1);
try {
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/FallbackSecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FallbackSecretProvider.java
index 369608f78df..cf3f879e7c9 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/FallbackSecretProvider.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FallbackSecretProvider.java
@@ -18,35 +18,40 @@
*******************************************************************************/
package org.apache.ofbiz.base.secret;
+import java.util.Properties;
+
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
/**
- * {@link SecretProvider} decorator that falls back to a secondary provider
- * when the primary provider fails to resolve a secret.
+ * {@link SecretProvider} decorator that retries the primary provider with exponential backoff
+ * before falling back to a secondary provider when the primary fails to resolve a secret.
*
*
This is used by {@link SecretProviderFactory} to wrap a custom (remote)
- * {@link SecretProvider} implementation together with a
- * {@link FileBasedSecretProvider}. If the primary provider throws a
- * {@link GeneralException} (e.g. the remote secret manager is unreachable or
- * the secret does not exist there) and {@link SecretProvider#isFallbackEnabled()}
- * returns {@code true} for the primary provider, the secret is resolved from
- * the fallback provider instead.
+ * {@link SecretProvider} implementation together with a {@link FileBasedSecretProvider}.
+ * On a transient failure the primary is retried up to {@code secret.provider.retry.count}
+ * times (default 2) with an initial delay of {@code secret.provider.retry.delay.ms}
+ * (default 500 ms) doubling on each attempt before the fallback is triggered.
*
- *
If the primary provider fails and fallback is disabled, or the fallback
- * provider also fails, the original exception from the primary provider is
- * propagated to the caller.
+ *
If the primary provider fails and fallback is disabled, or the fallback provider also
+ * fails, the original exception from the primary provider is propagated to the caller.
*/
public final class FallbackSecretProvider implements SecretProvider {
private static final String MODULE = FallbackSecretProvider.class.getName();
+ private static final Properties SECURITY_PROPERTIES = UtilProperties.getProperties("security");
+
+ private static final int RETRY_COUNT = readInt("secret.provider.retry.count", 2);
+ private static final long RETRY_DELAY_MS = readLong("secret.provider.retry.delay.ms", 500L);
+
private final SecretProvider primary;
private final SecretProvider fallback;
/**
* @param primary the primary (typically remote) provider to try first
- * @param fallback the provider to use if the primary fails and allows fallback
+ * @param fallback the provider to use if the primary fails after all retries and allows fallback
*/
public FallbackSecretProvider(SecretProvider primary, SecretProvider fallback) {
this.primary = primary;
@@ -55,23 +60,85 @@ public FallbackSecretProvider(SecretProvider primary, SecretProvider fallback) {
@Override
public String getSecret(String key) throws GeneralException {
- try {
- return primary.getSecret(key);
- } catch (GeneralException e) {
- if (!primary.isFallbackEnabled()) {
- throw e;
+ GeneralException lastException = null;
+ long delayMs = RETRY_DELAY_MS;
+
+ for (int attempt = 0; attempt <= RETRY_COUNT; attempt++) {
+ try {
+ return primary.getSecret(key);
+ } catch (GeneralException e) {
+ lastException = e;
+ if (!primary.isFallbackEnabled()) {
+ throw e;
+ }
+ if (attempt < RETRY_COUNT) {
+ Debug.logWarning("SecretProvider: " + primary.getClass().getName()
+ + " failed to resolve secret '" + key + "' (" + e.getClass().getSimpleName()
+ + "), retrying in " + delayMs + " ms (attempt " + (attempt + 1) + "/" + RETRY_COUNT + ")",
+ MODULE);
+ try {
+ Thread.sleep(delayMs);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ delayMs *= 2; // exponential backoff
+ }
}
- Debug.logWarning("SecretProvider: " + primary.getClass().getName()
- + " failed to resolve secret '" + key + "' (" + e.getMessage()
- + "), falling back to " + describe(fallback), MODULE);
- return fallback.getSecret(key);
+ }
+
+ // All retries exhausted — fall back to the secondary provider.
+ Debug.logWarning("SecretProvider: " + primary.getClass().getName()
+ + " failed after " + (RETRY_COUNT + 1) + " attempt(s) for secret '" + key
+ + "' (" + (lastException != null ? lastException.getClass().getSimpleName() : "unknown")
+ + "), falling back to " + describe(fallback), MODULE);
+ return fallback.getSecret(key);
+ }
+
+ /** Closes both the primary and fallback providers. */
+ @Override
+ public void close() {
+ try {
+ primary.close();
+ } finally {
+ fallback.close();
+ }
+ }
+
+ /** Clears the in-memory cache of both the primary and fallback providers. */
+ @Override
+ public void invalidateCache() {
+ try {
+ primary.invalidateCache();
+ } finally {
+ fallback.invalidateCache();
+ }
+ }
+
+ private static int readInt(String key, int defaultValue) {
+ if (SECURITY_PROPERTIES == null) {
+ return defaultValue;
+ }
+ try {
+ return Integer.parseInt(SECURITY_PROPERTIES.getProperty(key, String.valueOf(defaultValue)).trim());
+ } catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }
+
+ private static long readLong(String key, long defaultValue) {
+ if (SECURITY_PROPERTIES == null) {
+ return defaultValue;
+ }
+ try {
+ return Long.parseLong(SECURITY_PROPERTIES.getProperty(key, String.valueOf(defaultValue)).trim());
+ } catch (NumberFormatException e) {
+ return defaultValue;
}
}
/**
- * Returns a human-readable description of the given provider for log
- * messages, naming the backing {@code passwords.properties} file for
- * {@link FileBasedSecretProvider}.
+ * Returns a human-readable description of the given provider for log messages.
*/
private static String describe(SecretProvider provider) {
if (provider instanceof FileBasedSecretProvider) {
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
index c431f21d3be..2f04ba69ed5 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java
@@ -50,9 +50,20 @@ public interface SecretProvider {
/**
* Returns the secret value for the given key.
*
+ *
Contract when the key does not exist: implementations must throw
+ * {@link GeneralException} rather than returning {@code null} or an empty string.
+ * {@link FallbackSecretProvider} and {@link SecretValueResolver} rely on the exception to
+ * distinguish "key not present" from a successful empty-value lookup. Exception messages
+ * should contain text such as {@code "not found"}, {@code "NotFound"}, or
+ * {@code "does not exist"} so that callers (e.g. {@link
+ * org.apache.ofbiz.webtools.secret.SecretManagerServices#testSecretProviderConnection})
+ * can tell the difference between a missing key and a connectivity failure.
+ *
* @param key the identifier for the secret (e.g. {@code "jdbc-password.mydb"})
- * @return the resolved secret value, never {@code null} or empty
- * @throws GeneralException if the secret cannot be found or an error occurs during resolution
+ * @return the resolved secret value; never {@code null} or empty
+ * @throws GeneralException if the secret cannot be found or an error occurs during resolution;
+ * for a missing key the message should include "not found", "NotFound",
+ * or "does not exist"
*/
String getSecret(String key) throws GeneralException;
@@ -71,4 +82,32 @@ public interface SecretProvider {
default boolean isFallbackEnabled() {
return true;
}
+
+ /**
+ * Releases any resources held by this provider (e.g. HTTP connection pools, SDK clients).
+ * Called automatically by {@link SecretProviderFactory} via a JVM shutdown hook.
+ *
+ *
Implementations that hold long-lived SDK clients (e.g. AWS {@code SecretsManagerClient},
+ * GCP {@code SecretManagerServiceClient}) should override this method to close them cleanly.
+ *
+ *
The default implementation is a no-op, so providers with no closeable resources do not
+ * need to override it.
+ */
+ default void close() { }
+
+ /**
+ * Clears any secret values this provider has cached in memory, forcing the next
+ * {@link #getSecret(String)} call for each key to re-fetch from the remote vault.
+ *
+ *
Implementations that cache resolved values (all of the bundled vault providers do, with a
+ * TTL configured via their own {@code .cache.ttl.seconds} property) should override this
+ * method. It is called by {@link SecretProviderFactory#invalidateCache()}, which is in turn
+ * invoked by the Secret Manager admin screen's "Flush Secret Cache" action after a secret has
+ * been rotated in the remote vault, so the new value is picked up immediately without waiting
+ * for the provider's own TTL to expire.
+ *
+ *
The default implementation is a no-op, so providers with no internal cache do not need to
+ * override it.
+ */
+ default void invalidateCache() { }
}
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
index 5e5fef791de..b4c415ee9ad 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
@@ -19,10 +19,15 @@
package org.apache.ofbiz.base.secret;
import java.util.Iterator;
+import java.util.Properties;
import java.util.ServiceLoader;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilProperties;
/**
* Factory that provides the active {@link SecretProvider} instance.
@@ -48,23 +53,136 @@ public final class SecretProviderFactory {
private static final String MODULE = SecretProviderFactory.class.getName();
- private static final SecretProvider INSTANCE = loadProvider();
+ private static final Properties SECURITY_PROPERTIES = UtilProperties.getProperties("security");
- private static SecretProvider loadProvider() {
- Iterator it = ServiceLoader.load(SecretProvider.class).iterator();
- if (it.hasNext()) {
- SecretProvider provider = it.next();
- Debug.logInfo("SecretProvider: using custom implementation " + provider.getClass().getName(), MODULE);
- return new FallbackSecretProvider(provider, new FileBasedSecretProvider());
+ // Proactive rotation poll: disabled (0) by default. Set secret.rotation.poll.seconds in
+ // security.properties to periodically flush both cache layers so a secret rotated in the
+ // remote vault is picked up within a bounded window even if nobody clicks "Flush Secret Cache".
+ private static final long ROTATION_POLL_SECONDS = readPollSeconds();
+
+ private static volatile SecretProvider instance;
+ private static volatile String providerName;
+
+ static {
+ loadAndSet();
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ try {
+ instance.close();
+ Debug.logInfo("SecretProvider: provider closed on shutdown", MODULE);
+ } catch (Exception e) {
+ Debug.logWarning("SecretProvider: error closing provider on shutdown: " + e.getMessage(), MODULE);
+ }
+ }, "SecretProvider-Shutdown"));
+ if (ROTATION_POLL_SECONDS > 0) {
+ ScheduledExecutorService poll = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "SecretProvider-RotationPoll");
+ t.setDaemon(true);
+ return t;
+ });
+ poll.scheduleAtFixedRate(() -> {
+ try {
+ instance.invalidateCache();
+ SecretValueResolver.invalidateAll();
+ Debug.logInfo("[SECRET_AUDIT] user=system action=scheduledRotationPoll", MODULE);
+ } catch (Exception e) {
+ Debug.logWarning("SecretProvider: scheduled rotation poll failed: " + e.getMessage(), MODULE);
+ }
+ }, ROTATION_POLL_SECONDS, ROTATION_POLL_SECONDS, TimeUnit.SECONDS);
+ Debug.logInfo("SecretProvider: scheduled rotation poll enabled every "
+ + ROTATION_POLL_SECONDS + "s", MODULE);
+ }
+ }
+
+ private static long readPollSeconds() {
+ if (SECURITY_PROPERTIES == null) {
+ return 0L;
+ }
+ try {
+ return Long.parseLong(SECURITY_PROPERTIES.getProperty("secret.rotation.poll.seconds", "0").trim());
+ } catch (NumberFormatException e) {
+ return 0L;
+ }
+ }
+
+ /** Discovers and instantiates the active provider via {@link ServiceLoader}, setting {@code instance}/{@code providerName}. */
+ private static void loadAndSet() {
+ SecretProvider provider;
+ String name;
+ try {
+ Iterator it = ServiceLoader.load(SecretProvider.class).iterator();
+ if (it.hasNext()) {
+ SecretProvider custom = it.next();
+ name = custom.getClass().getSimpleName();
+ Debug.logInfo("SecretProvider: using custom implementation " + custom.getClass().getName(), MODULE);
+ if (it.hasNext()) {
+ StringBuilder extras = new StringBuilder();
+ it.forEachRemaining(p -> extras.append(" ").append(p.getClass().getName()));
+ Debug.logWarning("SecretProvider: multiple SecretProvider implementations found on the classpath."
+ + " Only '" + custom.getClass().getName() + "' will be used; ignoring:"
+ + extras + ". Remove unused provider plugins to eliminate ambiguity.", MODULE);
+ }
+ provider = new FallbackSecretProvider(custom, new FileBasedSecretProvider());
+ } else {
+ Debug.logInfo("SecretProvider: no custom implementation found, using FileBasedSecretProvider"
+ + " (framework/base/config/passwords.properties)", MODULE);
+ provider = new FileBasedSecretProvider();
+ name = "FileBasedSecretProvider (no vault configured)";
+ }
+ } catch (Exception e) {
+ // A plugin provider whose constructor throws (e.g. missing SDK dependency, bad config)
+ // must not prevent OFBiz from starting. Fall back to FileBasedSecretProvider and log
+ // a clear error so the operator knows the vault plugin was not loaded.
+ Debug.logError("SecretProvider: failed to load custom provider (" + e.getClass().getName()
+ + ": " + e.getMessage() + "). Falling back to FileBasedSecretProvider.", MODULE);
+ provider = new FileBasedSecretProvider();
+ name = "FileBasedSecretProvider (no vault configured)";
}
- Debug.logInfo("SecretProvider: no custom implementation found, using FileBasedSecretProvider"
- + " (framework/base/config/passwords.properties)", MODULE);
- return new FileBasedSecretProvider();
+ instance = provider;
+ providerName = name;
}
/** Returns the active {@link SecretProvider} instance. */
public static SecretProvider getInstance() {
- return INSTANCE;
+ return instance;
+ }
+
+ /**
+ * Returns the simple class name of the primary provider.
+ * When a vault plugin is loaded this is the plugin class (e.g. {@code AwsSecretsManagerProvider}),
+ * not the wrapping {@link FallbackSecretProvider}. When no plugin is loaded this is
+ * {@code FileBasedSecretProvider}.
+ */
+ public static String getProviderName() {
+ return providerName;
+ }
+
+ /** Clears the active provider's in-memory secret cache. See {@link SecretProvider#invalidateCache()}. */
+ public static void invalidateCache() {
+ instance.invalidateCache();
+ }
+
+ /**
+ * Re-runs {@link ServiceLoader} discovery and replaces the active provider, then closes the
+ * previous one. Useful after deploying a new vault plugin or updating that plugin's own
+ * connection credentials (e.g. a rotated AWS access key or HashiCorp AppRole secret_id) in its
+ * {@code config/*.properties} file, without requiring a full OFBiz restart.
+ *
+ *
Callers must ensure the relevant plugin config resource's property cache has already been
+ * cleared (e.g. via {@code UtilCache.clearCachesThatStartWith("properties.UtilProperties")}) so
+ * the new provider picks up the updated values.
+ */
+ public static synchronized void reload() {
+ SecretProvider previous = instance;
+ loadAndSet();
+ if (previous != null) {
+ try {
+ previous.close();
+ } catch (Exception e) {
+ Debug.logWarning("SecretProvider: error closing previous provider during reload: "
+ + e.getMessage(), MODULE);
+ }
+ }
+ Debug.logInfo("SecretProvider: provider reloaded -> " + providerName, MODULE);
}
private SecretProviderFactory() { }
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java
index 6dec75dc80c..f07d8537438 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java
@@ -18,11 +18,20 @@
*******************************************************************************/
package org.apache.ofbiz.base.secret;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilProperties;
@@ -52,6 +61,7 @@
* (default 300) to avoid calling the remote secret manager on every property
* lookup.
*/
+@ThreadSafe
public final class SecretValueResolver {
private static final String MODULE = SecretValueResolver.class.getName();
@@ -68,12 +78,53 @@ public final class SecretValueResolver {
private static final Pattern SECRET_PATTERN =
Pattern.compile("^" + Pattern.quote(MARKER_NAME) + "\\((.+)\\)$");
- private static final long CACHE_TTL_MILLIS = (SECURITY_PROPERTIES != null
- ? Long.parseLong(SECURITY_PROPERTIES.getProperty("secret.cache.ttl.seconds", "300").trim())
- : 300L) * 1000L;
+ // Cap at 86400 seconds (24 h) to prevent silent long-overflow when an operator enters
+ // an unreasonably large value in security.properties.
+ private static final long MAX_TTL_SECONDS = 86400L;
+ private static final long CACHE_TTL_MILLIS = parseTtlMillis();
+
+ private static long parseTtlMillis() {
+ long seconds = 300L;
+ if (SECURITY_PROPERTIES != null) {
+ try {
+ seconds = Long.parseLong(
+ SECURITY_PROPERTIES.getProperty("secret.cache.ttl.seconds", "300").trim());
+ } catch (NumberFormatException ignored) {
+ seconds = 300L;
+ }
+ }
+ return Math.min(Math.max(seconds, 1L), MAX_TTL_SECONDS) * 1000L;
+ }
private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>();
+ // Per-key locks used for stampede protection: only one thread fetches from the provider
+ // when a cache entry expires; others wait and then read the freshly cached value.
+ private static final ConcurrentHashMap KEY_LOCKS = new ConcurrentHashMap<>();
+
+ // Usage counters — incremented every time resolveKey() is called.
+ private static final AtomicLong TOTAL_HITS = new AtomicLong();
+ private static final AtomicLong TOTAL_MISSES = new AtomicLong();
+ private static final ConcurrentHashMap KEY_HIT_COUNTS = new ConcurrentHashMap<>();
+ private static final ConcurrentHashMap KEY_MISS_COUNTS = new ConcurrentHashMap<>();
+
+ // Daemon thread that sweeps expired entries so stale keys don't accumulate indefinitely.
+ private static final ScheduledExecutorService EVICTION_EXECUTOR;
+ static {
+ ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "SecretValueResolver-CacheEviction");
+ t.setDaemon(true);
+ return t;
+ });
+ executor.scheduleAtFixedRate(() -> {
+ long now = System.currentTimeMillis();
+ CACHE.entrySet().removeIf(e -> e.getValue().expiry <= now);
+ // Remove per-key locks for keys no longer in the cache to prevent unbounded growth.
+ KEY_LOCKS.keySet().removeIf(k -> !CACHE.containsKey(k));
+ }, CACHE_TTL_MILLIS, CACHE_TTL_MILLIS, TimeUnit.MILLISECONDS);
+ EVICTION_EXECUTOR = executor;
+ }
+
private SecretValueResolver() { }
/**
@@ -113,16 +164,140 @@ public static String resolveKey(String key) {
}
CacheEntry cached = CACHE.get(key);
if (cached != null && cached.expiry > System.currentTimeMillis()) {
+ TOTAL_HITS.incrementAndGet();
+ KEY_HIT_COUNTS.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
return cached.value;
}
- try {
- String secret = SecretProviderFactory.getInstance().getSecret(key);
- CACHE.put(key, new CacheEntry(secret, System.currentTimeMillis() + CACHE_TTL_MILLIS));
- return secret;
- } catch (GeneralException e) {
- Debug.logError("SecretValueResolver: failed to resolve secret '" + key + "': " + e.getMessage(), MODULE);
- return "";
+ // Per-key lock: only the first thread that finds a stale/absent entry fetches from the
+ // provider. All other threads for the same key wait, then read the freshly cached value.
+ Object lock = KEY_LOCKS.computeIfAbsent(key, k -> new Object());
+ synchronized (lock) {
+ // Double-check: another thread may have populated the cache while we waited.
+ CacheEntry rechecked = CACHE.get(key);
+ if (rechecked != null && rechecked.expiry > System.currentTimeMillis()) {
+ TOTAL_HITS.incrementAndGet();
+ KEY_HIT_COUNTS.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
+ return rechecked.value;
+ }
+ try {
+ String secret = SecretProviderFactory.getInstance().getSecret(key);
+ CACHE.put(key, new CacheEntry(secret, System.currentTimeMillis() + CACHE_TTL_MILLIS));
+ TOTAL_MISSES.incrementAndGet();
+ KEY_MISS_COUNTS.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
+ return secret;
+ } catch (GeneralException e) {
+ // Log only the exception type — never e.getMessage() — to prevent a provider that
+ // accidentally embeds a secret value in its error message from leaking it to the log.
+ Debug.logError("SecretValueResolver: failed to resolve secret '" + key
+ + "' (" + e.getClass().getSimpleName() + ")", MODULE);
+ TOTAL_MISSES.incrementAndGet();
+ KEY_MISS_COUNTS.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
+ return "";
+ }
+ }
+ }
+
+ /**
+ * Redacts sensitive patterns from a string before it is used in a log message or UI output.
+ * Currently masks {@code ENC(...)} blobs (replacing the base64 payload with {@code ***}) so
+ * that encrypted config values logged by accident are not directly usable.
+ *
+ * @param value the string to sanitize, possibly {@code null}
+ * @return the sanitized string, or {@code null} if the input is {@code null}
+ */
+ public static String maskSensitive(String value) {
+ if (value == null) {
+ return null;
}
+ return value.replaceAll("ENC\\([^)]*\\)", "ENC(***)");
+ }
+
+ /**
+ * Removes the cached value for {@code key}, forcing the next {@link #resolve(String)} or
+ * {@link #resolveKey(String)} call to re-fetch from the provider. Call this after writing a
+ * new encrypted value for {@code key} so the update is visible immediately without waiting for
+ * the TTL to expire.
+ *
+ * @param key the raw secret key (without any {@code LOOKUP(...)} wrapper), or {@code null} to no-op
+ */
+ public static void invalidate(String key) {
+ if (key != null) {
+ CACHE.remove(key);
+ KEY_LOCKS.remove(key);
+ }
+ }
+
+ /**
+ * Clears all cached secret values, forcing every subsequent lookup to re-fetch from the
+ * provider. Useful after a bulk secret rotation or when an admin explicitly flushes the cache
+ * via the Secret Manager admin screen.
+ */
+ public static void invalidateAll() {
+ CACHE.clear();
+ KEY_LOCKS.clear();
+ Debug.logInfo("SecretValueResolver: secret value cache flushed", MODULE);
+ }
+
+ /**
+ * Resets all in-memory usage counters to zero. Useful when an operator wants to observe
+ * clean post-rotation metrics without waiting for a JVM restart.
+ */
+ public static void resetUsageStats() {
+ TOTAL_HITS.set(0);
+ TOTAL_MISSES.set(0);
+ KEY_HIT_COUNTS.clear();
+ KEY_MISS_COUNTS.clear();
+ Debug.logInfo("SecretValueResolver: usage stats reset", MODULE);
+ }
+
+ /**
+ * Returns a snapshot of usage statistics for the secret value cache. Each entry in the
+ * returned map represents one unique key that has been looked up since the last JVM start.
+ * The map is sorted by total lookup count (descending) for easy review in the admin UI.
+ *
+ *
Map structure: {@code key → {"hits": n, "misses": m, "total": n+m}}
+ */
+ public static Map> getUsageReport() {
+ // Collect all known keys from both hit and miss maps.
+ Set keys = new LinkedHashSet<>(KEY_HIT_COUNTS.keySet());
+ keys.addAll(KEY_MISS_COUNTS.keySet());
+
+ Map> report = new LinkedHashMap<>();
+ keys.stream()
+ .sorted((a, b) -> {
+ long totalA = getCount(KEY_HIT_COUNTS, a) + getCount(KEY_MISS_COUNTS, a);
+ long totalB = getCount(KEY_HIT_COUNTS, b) + getCount(KEY_MISS_COUNTS, b);
+ return Long.compare(totalB, totalA); // descending
+ })
+ .forEach(key -> {
+ long hits = getCount(KEY_HIT_COUNTS, key);
+ long misses = getCount(KEY_MISS_COUNTS, key);
+ Map stats = new LinkedHashMap<>();
+ stats.put("hits", hits);
+ stats.put("misses", misses);
+ stats.put("total", hits + misses);
+ report.put(key, stats);
+ });
+ return report;
+ }
+
+ /** Returns the aggregate hit and miss totals since JVM start as a simple summary map. */
+ public static Map getUsageSummary() {
+ // Capture atomics once to avoid a TOCTOU race where a concurrent hit/miss
+ // arrives between two get() calls and makes totalLookups != totalHits + totalMisses.
+ long hits = TOTAL_HITS.get();
+ long misses = TOTAL_MISSES.get();
+ Map summary = new LinkedHashMap<>();
+ summary.put("totalHits", hits);
+ summary.put("totalMisses", misses);
+ summary.put("totalLookups", hits + misses);
+ summary.put("cachedKeys", (long) CACHE.size());
+ return summary;
+ }
+
+ private static long getCount(ConcurrentHashMap map, String key) {
+ AtomicLong counter = map.get(key);
+ return counter == null ? 0L : counter.get();
}
private static final class CacheEntry {
diff --git a/framework/base/src/test/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtilTest.java b/framework/base/src/test/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtilTest.java
new file mode 100644
index 00000000000..4a26b4415a4
--- /dev/null
+++ b/framework/base/src/test/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtilTest.java
@@ -0,0 +1,116 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.base.crypto;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
+import static org.junit.Assume.assumeNotNull;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.junit.Test;
+
+public class ConfigCryptoUtilTest {
+
+ private static final String TEST_KEY = "test-master-key-for-unit-tests";
+
+ @Test
+ public void encryptDecryptRoundTrip() throws GeneralException {
+ String original = "my-secret-password";
+ String encrypted = ConfigCryptoUtil.encrypt(original, TEST_KEY);
+ assertEquals(original, ConfigCryptoUtil.decrypt(encrypted, TEST_KEY));
+ }
+
+ @Test
+ public void eachEncryptionProducesUniqueCiphertext() throws GeneralException {
+ // Random IV means identical plaintexts should not produce identical ciphertext.
+ String enc1 = ConfigCryptoUtil.encrypt("same-value", TEST_KEY);
+ String enc2 = ConfigCryptoUtil.encrypt("same-value", TEST_KEY);
+ assertNotEquals(enc1, enc2);
+ }
+
+ @Test(expected = GeneralException.class)
+ public void decryptWithWrongKeyThrows() throws GeneralException {
+ String encrypted = ConfigCryptoUtil.encrypt("secret", TEST_KEY);
+ ConfigCryptoUtil.decrypt(encrypted, "wrong-key");
+ }
+
+ @Test
+ public void decryptIfEncryptedReturnsPlainValueUnchanged() throws GeneralException {
+ assertEquals("plain-value", ConfigCryptoUtil.decryptIfEncrypted("plain-value", "test-secret"));
+ assertEquals("notenc", ConfigCryptoUtil.decryptIfEncrypted("notenc", "test-secret"));
+ // Partial ENC pattern must not be treated as encrypted
+ assertEquals("ENC(noclosepar", ConfigCryptoUtil.decryptIfEncrypted("ENC(noclosepar", "test-secret"));
+ }
+
+ @Test
+ public void decryptIfEncryptedEncWrapperIsStrippedBeforeDecrypt() throws GeneralException {
+ // Verify that the "ENC(...)" wrapper is stripped correctly: the base64 content inside
+ // ENC(...) must be identical to what encrypt() returns, so that decrypt() can reverse it.
+ String plain = "strip-wrapper-test";
+ String base64 = ConfigCryptoUtil.encrypt(plain, TEST_KEY);
+ // decryptIfEncrypted would strip "ENC(" and ")" and pass base64 to decrypt().
+ // Confirm the strip round-trip is correct by doing it manually:
+ String encWrapped = "ENC(" + base64 + ")";
+ String stripped = encWrapped.substring("ENC(".length(), encWrapped.length() - 1);
+ assertEquals(plain, ConfigCryptoUtil.decrypt(stripped, TEST_KEY));
+ }
+
+ @Test
+ public void decryptIfEncryptedHappyPath() throws GeneralException {
+ // Only runs when OFBIZ_MASTER_KEY is set to TEST_KEY in the environment,
+ // allowing the full decryptIfEncrypted() path to be exercised end-to-end.
+ String envKey = System.getenv(ConfigCryptoUtil.MASTER_KEY_ENV_VAR);
+ assumeNotNull("Skipped: " + ConfigCryptoUtil.MASTER_KEY_ENV_VAR + " is not set", envKey);
+ assumeTrue("Skipped: " + ConfigCryptoUtil.MASTER_KEY_ENV_VAR + " must equal TEST_KEY for this test",
+ TEST_KEY.equals(envKey));
+ String plain = "happy-path-secret";
+ String encWrapped = "ENC(" + ConfigCryptoUtil.encrypt(plain, TEST_KEY) + ")";
+ assertEquals(plain, ConfigCryptoUtil.decryptIfEncrypted(encWrapped, "test-secret"));
+ }
+
+ @Test
+ public void iterationCountIsPositive() {
+ // Verifies that readIterations() returned a usable value regardless of what security.properties contains.
+ assertTrue("ITERATIONS must be positive", ConfigCryptoUtil.ITERATIONS > 0);
+ }
+
+ @Test
+ public void iterationCountMatchesSecurityPropertiesDefault() {
+ // When no security.properties override is present in the test environment, the default of 310000 is used.
+ // If an override is present, we just verify the value is still positive.
+ assertTrue("ITERATIONS must be at least 1", ConfigCryptoUtil.ITERATIONS >= 1);
+ }
+
+ @Test
+ public void decryptIfEncryptedThrowsWhenMasterKeyMissing() throws GeneralException {
+ assumeTrue("Skipped: OFBIZ_MASTER_KEY is set in this environment",
+ System.getenv("OFBIZ_MASTER_KEY") == null || System.getenv("OFBIZ_MASTER_KEY").isEmpty());
+ String encWrapped = "ENC(" + ConfigCryptoUtil.encrypt("secret", TEST_KEY) + ")";
+ try {
+ ConfigCryptoUtil.decryptIfEncrypted(encWrapped, "test-secret");
+ fail("Expected GeneralException when OFBIZ_MASTER_KEY is absent");
+ } catch (GeneralException e) {
+ assertTrue("Exception message should mention OFBIZ_MASTER_KEY",
+ e.getMessage().contains("OFBIZ_MASTER_KEY"));
+ }
+ }
+}
diff --git a/framework/base/src/test/java/org/apache/ofbiz/base/secret/FallbackSecretProviderTest.java b/framework/base/src/test/java/org/apache/ofbiz/base/secret/FallbackSecretProviderTest.java
new file mode 100644
index 00000000000..661f6e390e7
--- /dev/null
+++ b/framework/base/src/test/java/org/apache/ofbiz/base/secret/FallbackSecretProviderTest.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.base.secret;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.junit.Test;
+
+/**
+ * Tests {@link FallbackSecretProvider} retry and fallback behavior.
+ *
+ *
Note: {@link #allRetriesFailedDelegatesToFallback()} exercises the full retry loop and
+ * takes ~1.5 s due to exponential backoff (500 ms + 1000 ms with default security.properties).
+ * This is intentional — it validates the real production timing.
+ */
+public class FallbackSecretProviderTest {
+
+ @Test
+ public void primarySuccessDoesNotCallFallback() throws GeneralException {
+ AtomicBoolean fallbackCalled = new AtomicBoolean(false);
+ SecretProvider primary = fixedProvider("primary-value", true);
+ SecretProvider fallback = new SecretProvider() {
+ @Override
+ public String getSecret(String key) {
+ fallbackCalled.set(true);
+ return "fallback-value";
+ }
+ };
+ assertEquals("primary-value", new FallbackSecretProvider(primary, fallback).getSecret("k"));
+ assertFalse("Fallback must not be called when primary succeeds", fallbackCalled.get());
+ }
+
+ @Test(expected = GeneralException.class)
+ public void fallbackDisabledRethrowsImmediately() throws GeneralException {
+ SecretProvider primary = throwingProvider(new GeneralException("vault-error"), false);
+ SecretProvider fallback = fixedProvider("fallback-value", true);
+ new FallbackSecretProvider(primary, fallback).getSecret("k");
+ }
+
+ @Test
+ public void fallbackDisabledDoesNotConsultFallback() throws GeneralException {
+ AtomicBoolean fallbackCalled = new AtomicBoolean(false);
+ SecretProvider primary = throwingProvider(new GeneralException("vault-error"), false);
+ SecretProvider fallback = new SecretProvider() {
+ @Override
+ public String getSecret(String key) {
+ fallbackCalled.set(true);
+ return "fallback-value";
+ }
+ };
+ try {
+ new FallbackSecretProvider(primary, fallback).getSecret("k");
+ } catch (GeneralException ignored) { }
+ assertFalse("Fallback must never be called when isFallbackEnabled=false", fallbackCalled.get());
+ }
+
+ @Test
+ public void allRetriesFailedDelegatesToFallback() throws GeneralException {
+ SecretProvider primary = throwingProvider(new GeneralException("vault-unavailable"), true);
+ SecretProvider fallback = fixedProvider("fallback-value", true);
+ assertEquals("fallback-value", new FallbackSecretProvider(primary, fallback).getSecret("k"));
+ }
+
+ @Test
+ public void primaryIsCalledMoreThanOnceBeforeFallback() throws GeneralException {
+ AtomicInteger callCount = new AtomicInteger(0);
+ SecretProvider primary = new SecretProvider() {
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ callCount.incrementAndGet();
+ throw new GeneralException("fail");
+ }
+ @Override
+ public boolean isFallbackEnabled() {
+ return true;
+ }
+ };
+ SecretProvider fallback = fixedProvider("fallback-value", true);
+ new FallbackSecretProvider(primary, fallback).getSecret("k");
+ // With default retry count of 2, primary must be called 3 times (initial + 2 retries).
+ assertTrue("Primary must be called more than once before fallback",
+ callCount.get() > 1);
+ }
+
+ @Test
+ public void closeDelegatesToFallbackEvenIfPrimaryThrows() {
+ AtomicBoolean fallbackClosed = new AtomicBoolean(false);
+ SecretProvider primary = new SecretProvider() {
+ @Override
+ public String getSecret(String key) { return ""; }
+ @Override
+ public void close() { throw new RuntimeException("primary-close-error"); }
+ };
+ SecretProvider fallback = new SecretProvider() {
+ @Override
+ public String getSecret(String key) { return ""; }
+ @Override
+ public void close() { fallbackClosed.set(true); }
+ };
+ try {
+ new FallbackSecretProvider(primary, fallback).close();
+ } catch (RuntimeException ignored) { }
+ assertTrue("fallback.close() must always be called (try-finally)", fallbackClosed.get());
+ }
+
+ // -- helpers --
+
+ private static SecretProvider fixedProvider(String value, boolean fallbackEnabled) {
+ return new SecretProvider() {
+ @Override
+ public String getSecret(String key) { return value; }
+ @Override
+ public boolean isFallbackEnabled() { return fallbackEnabled; }
+ };
+ }
+
+ private static SecretProvider throwingProvider(GeneralException ex, boolean fallbackEnabled) {
+ return new SecretProvider() {
+ @Override
+ public String getSecret(String key) throws GeneralException { throw ex; }
+ @Override
+ public boolean isFallbackEnabled() { return fallbackEnabled; }
+ };
+ }
+}
diff --git a/framework/base/src/test/java/org/apache/ofbiz/base/secret/FileBasedSecretProviderTest.java b/framework/base/src/test/java/org/apache/ofbiz/base/secret/FileBasedSecretProviderTest.java
new file mode 100644
index 00000000000..91705d43695
--- /dev/null
+++ b/framework/base/src/test/java/org/apache/ofbiz/base/secret/FileBasedSecretProviderTest.java
@@ -0,0 +1,69 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.base.secret;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.junit.Test;
+
+/**
+ * Tests {@link FileBasedSecretProvider} in isolation using the
+ * {@code jdbc-password.h2-ofbiz} entry already present in
+ * {@code framework/base/config/passwords.properties}.
+ */
+public class FileBasedSecretProviderTest {
+
+ private final FileBasedSecretProvider provider = new FileBasedSecretProvider();
+
+ @Test
+ public void getSecretReturnsValueForKnownKey() throws GeneralException {
+ assertEquals("ofbiz", provider.getSecret("jdbc-password.h2-ofbiz"));
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecretThrowsForUnknownKey() throws GeneralException {
+ provider.getSecret("jdbc-password.no-such-key-xyz");
+ }
+
+ @Test
+ public void getSecretExceptionMentionsKeyName() {
+ try {
+ provider.getSecret("missing-key-abc");
+ } catch (GeneralException e) {
+ assertNotNull(e.getMessage());
+ assertTrue("Exception should mention the key name", e.getMessage().contains("missing-key-abc"));
+ return;
+ }
+ // If no exception was thrown the test fails here.
+ assertTrue("Expected GeneralException for missing key", false);
+ }
+
+ @Test
+ public void isFallbackEnabledDefaultsToTrue() {
+ assertTrue(provider.isFallbackEnabled());
+ }
+
+ @Test
+ public void closeIsNoOp() {
+ provider.close(); // must not throw
+ }
+}
diff --git a/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretProviderFactoryTest.java b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretProviderFactoryTest.java
new file mode 100644
index 00000000000..009a23b32b0
--- /dev/null
+++ b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretProviderFactoryTest.java
@@ -0,0 +1,62 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.base.secret;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Tests {@link SecretProviderFactory} static accessors.
+ *
+ *
In the test environment no custom {@link SecretProvider} plugin is on the
+ * {@link java.util.ServiceLoader} classpath, so the factory always selects
+ * {@link FileBasedSecretProvider}.
+ */
+public class SecretProviderFactoryTest {
+
+ @Test
+ public void instanceIsNotNull() {
+ assertNotNull(SecretProviderFactory.getInstance());
+ }
+
+ @Test
+ public void instanceIsSingleton() {
+ assertSame(SecretProviderFactory.getInstance(), SecretProviderFactory.getInstance());
+ }
+
+ @Test
+ public void providerNameIsNotNullOrEmpty() {
+ String name = SecretProviderFactory.getProviderName();
+ assertNotNull(name);
+ assertFalse("Provider name must not be empty", name.isEmpty());
+ }
+
+ @Test
+ public void providerNameIndicatesFileBasedWhenNoPluginPresent() {
+ // In the test environment no vault plugin is on the ServiceLoader classpath,
+ // so the factory falls back to FileBasedSecretProvider.
+ String name = SecretProviderFactory.getProviderName();
+ assertTrue("Expected 'FileBasedSecretProvider' in provider name when no plugin is configured, got: " + name,
+ name.contains("FileBasedSecretProvider"));
+ }
+}
diff --git a/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java
index 21bba1a29b1..14ff3c2277b 100644
--- a/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java
+++ b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java
@@ -19,9 +19,14 @@
package org.apache.ofbiz.base.secret;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Map;
import org.junit.Test;
+import org.junit.Before;
/**
* Tests {@link SecretValueResolver} using the default {@link FileBasedSecretProvider}
@@ -31,6 +36,13 @@
*/
public class SecretValueResolverTest {
+ private static final String M = SecretValueResolver.MARKER_NAME;
+
+ @Before
+ public void clearCache() {
+ SecretValueResolver.invalidateAll();
+ }
+
@Test
public void nonSecretValuesAreReturnedUnchanged() {
assertEquals("plainvalue", SecretValueResolver.resolve("plainvalue"));
@@ -45,13 +57,122 @@ public void nullIsReturnedUnchanged() {
@Test
public void resolvesKnownKeyFromPasswordsProperties() {
- assertEquals("ofbiz", SecretValueResolver.resolve("LOOKUP(jdbc-password.h2-ofbiz)"));
+ assertEquals("ofbiz", SecretValueResolver.resolve(M + "(jdbc-password.h2-ofbiz)"));
// Cached lookup should return the same value.
- assertEquals("ofbiz", SecretValueResolver.resolve("LOOKUP(jdbc-password.h2-ofbiz)"));
+ assertEquals("ofbiz", SecretValueResolver.resolve(M + "(jdbc-password.h2-ofbiz)"));
}
@Test
public void unresolvableKeyReturnsEmptyString() {
- assertEquals("", SecretValueResolver.resolve("LOOKUP(jdbc-password.does-not-exist)"));
+ assertEquals("", SecretValueResolver.resolve(M + "(jdbc-password.does-not-exist)"));
+ }
+
+ @Test
+ public void resolveKeyResolvesDirectly() {
+ assertEquals("ofbiz", SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz"));
+ }
+
+ @Test
+ public void resolveKeyWithNullReturnsNull() {
+ assertNull(SecretValueResolver.resolveKey(null));
+ }
+
+ @Test
+ public void resolveKeyWithEmptyReturnsEmpty() {
+ assertEquals("", SecretValueResolver.resolveKey(""));
+ }
+
+ @Test
+ public void invalidateForcesFreshFetch() {
+ // Populate cache, then invalidate and verify re-fetch still returns the same value.
+ assertEquals("ofbiz", SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz"));
+ SecretValueResolver.invalidate("jdbc-password.h2-ofbiz");
+ assertEquals("ofbiz", SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz"));
+ }
+
+ @Test
+ public void invalidateNullIsNoOp() {
+ SecretValueResolver.invalidate(null); // must not throw
+ }
+
+ @Test
+ public void invalidateAllClearsAllEntries() {
+ SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz");
+ SecretValueResolver.invalidateAll(); // must not throw
+ // After invalidation, the key can still be resolved (re-fetched from provider).
+ assertEquals("ofbiz", SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz"));
+ }
+
+ @Test
+ public void maskSensitiveRedactsEncBlob() {
+ assertEquals("ENC(***)", SecretValueResolver.maskSensitive("ENC(abc123==)"));
+ }
+
+ @Test
+ public void maskSensitiveRedactsMultipleEncBlobs() {
+ assertEquals("a=ENC(***) b=ENC(***)",
+ SecretValueResolver.maskSensitive("a=ENC(first) b=ENC(second)"));
+ }
+
+ @Test
+ public void maskSensitiveLeavesPlainValueUnchanged() {
+ assertEquals("plain-value", SecretValueResolver.maskSensitive("plain-value"));
+ }
+
+ @Test
+ public void maskSensitiveWithNullReturnsNull() {
+ assertNull(SecretValueResolver.maskSensitive(null));
+ }
+
+ @Test
+ public void resetUsageStatsClearsAllCounters() {
+ // Populate counters by doing some lookups.
+ SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz"); // miss then hit
+ SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz"); // hit
+ SecretValueResolver.resolveKey("jdbc-password.does-not-exist"); // miss
+
+ // Reset and verify summary is zeroed.
+ SecretValueResolver.resetUsageStats();
+ Map summary = SecretValueResolver.getUsageSummary();
+ assertEquals(Long.valueOf(0), summary.get("totalHits"));
+ assertEquals(Long.valueOf(0), summary.get("totalMisses"));
+ assertEquals(Long.valueOf(0), summary.get("totalLookups"));
+ }
+
+ @Test
+ public void resetUsageStatsAlsoClearsPerKeyReport() {
+ SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz");
+ SecretValueResolver.resetUsageStats();
+ Map> report = SecretValueResolver.getUsageReport();
+ assertTrue("Per-key report should be empty after reset", report.isEmpty());
+ }
+
+ @Test
+ public void getUsageSummaryReturnsRequiredKeys() {
+ Map summary = SecretValueResolver.getUsageSummary();
+ assertNotNull(summary.get("totalHits"));
+ assertNotNull(summary.get("totalMisses"));
+ assertNotNull(summary.get("totalLookups"));
+ assertNotNull(summary.get("cachedKeys"));
+ }
+
+ @Test
+ public void getUsageSummaryTotalIsConsistent() {
+ SecretValueResolver.resetUsageStats();
+ SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz");
+ Map summary = SecretValueResolver.getUsageSummary();
+ assertEquals(summary.get("totalHits") + summary.get("totalMisses"), (long) summary.get("totalLookups"));
+ }
+
+ @Test
+ public void getUsageReportTracksKnownKey() {
+ SecretValueResolver.resetUsageStats();
+ SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz");
+ SecretValueResolver.resolveKey("jdbc-password.h2-ofbiz");
+ Map> report = SecretValueResolver.getUsageReport();
+ assertTrue("Known key should appear in per-key report", report.containsKey("jdbc-password.h2-ofbiz"));
+ Map stats = report.get("jdbc-password.h2-ofbiz");
+ long total = stats.get("hits") + stats.get("misses");
+ assertEquals(total, (long) stats.get("total"));
}
}
diff --git a/framework/common/entitydef/entitymodel.xml b/framework/common/entitydef/entitymodel.xml
index 760c7606618..9dfb3e1abaf 100644
--- a/framework/common/entitydef/entitymodel.xml
+++ b/framework/common/entitydef/entitymodel.xml
@@ -890,6 +890,7 @@ under the License.
+
diff --git a/framework/security/config/security.properties b/framework/security/config/security.properties
index 0f2963e325a..0d5b1cbf761 100644
--- a/framework/security/config/security.properties
+++ b/framework/security/config/security.properties
@@ -429,4 +429,24 @@ freemarker.use-restricted-static-models=true
secret.value.marker=LOOKUP
# -- How long (in seconds) to cache resolved secret values before re-fetching from the provider.
# -- Increase for high-throughput installs; decrease for environments with frequent secret rotation.
+# -- Capped at 86400 seconds (24 hours) regardless of the value set here; values below 1 are treated as 1.
secret.cache.ttl.seconds=300
+# -- Proactive rotation poll interval in seconds. When greater than 0, a background task flushes both
+# -- the SecretValueResolver cache and the active SecretProvider's own cache at this interval, so a
+# -- secret rotated in the remote vault is picked up within a bounded window even if nobody manually
+# -- triggers "Flush Secret Cache" in the admin screen. Disabled (0) by default — most installs rely on
+# -- the per-provider cache.ttl.seconds expiring on its own, or an admin/automation explicitly flushing.
+secret.rotation.poll.seconds=0
+# -- Number of times to retry the primary SecretProvider before falling back to passwords.properties.
+# -- Set to 0 to disable retries (fall back immediately on first failure).
+secret.provider.retry.count=2
+# -- Initial delay in milliseconds between retry attempts. Doubles on each subsequent attempt (exponential backoff).
+secret.provider.retry.delay.ms=500
+# -- Name of the environment variable that holds the AES master key used to encrypt/decrypt ENC(...) values.
+# -- Override this if your deployment already uses a different variable name for the key material.
+secret.master.key.env.var=OFBIZ_MASTER_KEY
+# -- PBKDF2-HMAC-SHA256 iteration count used when deriving the AES key from the master key.
+# -- WARNING: changing this value makes all existing ENC(...) values unreadable — you must
+# -- re-encrypt every secret after changing this setting. Default: 310000 (OWASP 2023 guidance).
+# -- Legacy installations that encrypted with 10000 iterations must keep this at 10000.
+secret.pbkdf2.iterations=310000
diff --git a/framework/webtools/config/WebtoolsUiLabels.xml b/framework/webtools/config/WebtoolsUiLabels.xml
index 9eff2e0c5de..7253a422a10 100644
--- a/framework/webtools/config/WebtoolsUiLabels.xml
+++ b/framework/webtools/config/WebtoolsUiLabels.xml
@@ -2093,6 +2093,81 @@
Upload and Encrypt
+
+ Confirm Secret Value
+
+
+ Secret Value and Confirm Secret Value must match.
+
+
+ Force an immediate re-fetch from the secret provider by flushing all cached values.
+ Use this after rotating a secret so the new value is picked up without restarting OFBiz.
+
+
+ Flush Secret Cache
+
+
+ Re-discover the active secret provider via ServiceLoader. Use this after deploying
+ a new vault plugin jar or editing that plugin's own connection settings, without restarting OFBiz.
+
+
+ Reload Secret Provider
+
+
+ Fetch the current value of a key from the active secret provider and re-encrypt it into
+ the local fallback snapshot. Use this after rotating a secret in the remote vault to keep the local
+ ENC(...) fallback (used when the vault is unreachable) up to date.
+
+
+ Sync From Provider
+
+
+ Verify that the active secret provider is reachable by resolving a known key.
+ If the key exists the secret value is confirmed but never displayed.
+ A "key not found" response still confirms vault connectivity.
+
+
+ Test Key
+
+
+ Test Connection
+
+
+ Active Secret Provider
+
+
+ Secret lookup statistics since the last JVM start. Hit = served from cache; Miss = fetched from provider.
+
+
+ Show Usage Stats
+
+
+ Reset Stats
+
+
+ Cache Hits
+
+
+ Provider Fetches
+
+
+ Total Lookups
+
+
+ Keys In Cache
+
+
+ Secret Key
+
+
+ Cache Hits
+
+
+ Provider Fetches
+
+
+ Total
+ Entität XML ToolsEntity XML Tools
@@ -4243,6 +4318,9 @@
性能测试性能測試
+
+ You do not have permission to manage secrets. The SECRET_MAINT or ENTITY_MAINT permission is required.
+ WebTools AutorisierungsfehlerWeb Tools Permission Error
diff --git a/framework/webtools/data/WebtoolsSecurityPermissionSeedData.xml b/framework/webtools/data/WebtoolsSecurityPermissionSeedData.xml
index 196acc8d0f4..a060edca164 100644
--- a/framework/webtools/data/WebtoolsSecurityPermissionSeedData.xml
+++ b/framework/webtools/data/WebtoolsSecurityPermissionSeedData.xml
@@ -44,6 +44,9 @@ under the License.
+
+
+
@@ -70,6 +73,7 @@ under the License.
+
diff --git a/framework/webtools/servicedef/services.xml b/framework/webtools/servicedef/services.xml
index 1a957b02273..d4e0829f9c9 100644
--- a/framework/webtools/servicedef/services.xml
+++ b/framework/webtools/servicedef/services.xml
@@ -122,17 +122,69 @@ under the License.
if the user has the ENTITY_MAINT permission.
+
+ Returns hasPermission=true if the user holds the SECRET_MAINT permission
+ (or the broader ENTITY_MAINT permission for backward compatibility).
+
+ Encrypts a secret value with ConfigCryptoUtil (AES-256-GCM, keyed by the OFBIZ_MASTER_KEY
environment variable) and stores the resulting ENC(...) value either as a SystemProperty.systemPropertyValue
or as a jdbc-password.<lookupKey> entry in passwords.properties.
-
+
+
+
+
+ Flushes all in-memory cached secret values (SecretValueResolver TTL cache, the UtilProperties
+ file cache, and the active SecretProvider's own internal cache) so the next lookup re-fetches from the
+ provider. Useful after a secret rotation.
+
+
+
+ Re-runs ServiceLoader discovery and replaces the active SecretProvider instance. Useful after
+ deploying a new vault plugin jar or editing that plugin's own connection settings without a restart.
+
+
+
+ Fetches the current value of lookupKey from the active SecretProvider and re-encrypts it into
+ the local fallback snapshot (passwords.properties or SystemProperty.systemPropertyValue), keeping the
+ ENC(...) value used when the remote vault is unreachable in sync with the latest rotation.
+
+
+
+
+
+
+
+ Tests connectivity to the active SecretProvider by resolving a given key.
+ Distinguishes "connected but key not found" from a real connection failure.
+
+
+
+
+ Returns in-memory usage statistics (hit/miss counts) for the SecretValueResolver cache
+ since the last JVM start, for display in the Secret Manager admin screen.
+
+
+
+
+
+ Resets in-memory secret usage counters to zero so operators can observe
+ clean post-rotation metrics without a JVM restart.
+ Saves service and related artifacts diagram to an Apple EOModelBundle file.
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/WebToolsServices.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/WebToolsServices.java
index 6f9fda106ad..cd22ca7c19d 100644
--- a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/WebToolsServices.java
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/WebToolsServices.java
@@ -962,6 +962,26 @@ public static Map entityMaintPermCheck(DispatchContext dctx, Map
return resultMap;
}
+ /**
+ * Performs a secret management security check. Returns hasPermission=true if the user has
+ * the {@code SECRET_MAINT} permission or the broader {@code ENTITY_MAINT} permission
+ * (backward-compatible: existing admins with ENTITY_MAINT are not locked out).
+ */
+ public static Map secretMaintPermCheck(DispatchContext dctx, Map context) {
+ GenericValue userLogin = (GenericValue) context.get("userLogin");
+ Locale locale = (Locale) context.get("locale");
+ Security security = dctx.getSecurity();
+ if (security.hasPermission("SECRET_MAINT", userLogin) || security.hasPermission("ENTITY_MAINT", userLogin)) {
+ Map resultMap = ServiceUtil.returnSuccess();
+ resultMap.put("hasPermission", true);
+ return resultMap;
+ }
+ Map resultMap = ServiceUtil.returnFailure(
+ UtilProperties.getMessage(RESOURCE, "WebtoolsSecretPermissionError", locale));
+ resultMap.put("hasPermission", false);
+ return resultMap;
+ }
+
public static Map exportServiceEoModelBundle(DispatchContext dctx, Map context) {
String eomodeldFullPath = (String) context.get("eomodeldFullPath");
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
index 2c08c4c702e..999c5cac296 100644
--- a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
@@ -27,7 +27,6 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
-import java.util.regex.Pattern;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -65,8 +64,6 @@ public final class SecretManagerEvents {
List.of("target", "systemResourceId", "systemPropertyId", "lookupKey", "secretValue");
private static final Set VALID_TARGETS = Set.of(
SecretManagerServices.TARGET_SYSTEM_PROPERTY, SecretManagerServices.TARGET_PASSWORDS_FILE);
- /** Allows only letters, digits, dots, hyphens, underscores — prevents path traversal and property-file injection. */
- private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[\\w.\\-]+$");
private SecretManagerEvents() { }
@@ -75,7 +72,9 @@ public static String uploadEncryptedSecrets(HttpServletRequest request, HttpServ
Security security = (Security) request.getAttribute("security");
GenericValue userLogin = (GenericValue) request.getSession().getAttribute("userLogin");
- if (security == null || !security.hasPermission("ENTITY_MAINT", userLogin)) {
+ if (security == null
+ || (!security.hasPermission("SECRET_MAINT", userLogin)
+ && !security.hasPermission("ENTITY_MAINT", userLogin))) {
request.setAttribute("_ERROR_MESSAGE_", "You do not have permission to perform this operation");
return "error";
}
@@ -118,6 +117,7 @@ public static String uploadEncryptedSecrets(HttpServletRequest request, HttpServ
long rowNum = record.getRecordNumber() + 1;
try {
SecretManagerServices.storeEncryptedSecret(delegator,
+ userLogin,
getColumn(record, "target"),
getColumn(record, "systemResourceId"),
getColumn(record, "systemPropertyId"),
@@ -167,7 +167,7 @@ private static CSVFormat buildCsvFormat() {
* {@code ..} path-traversal sequences.
*
*/
- private static List validateCsvContent(byte[] csvBytes) throws IOException {
+ static List validateCsvContent(byte[] csvBytes) throws IOException {
List errors = new ArrayList<>();
// Whole-file content scan using OFBiz's existing SecuredUpload allow-list validator.
@@ -223,16 +223,21 @@ private static List validateCsvContent(byte[] csvBytes) throws IOExcepti
return errors;
}
- /** Validates that {@code value} contains only safe identifier characters and no {@code ..}. */
+ /** Validates that {@code value} contains only safe identifier characters, no {@code ..}, and is within length. */
private static void validateIdentifier(List errors, long rowNum, String field, String value) {
if (value == null) {
return;
}
+ if (value.length() > SecretManagerServices.MAX_KEY_LENGTH) {
+ errors.add("Row " + rowNum + ": '" + field + "' must not exceed "
+ + SecretManagerServices.MAX_KEY_LENGTH + " characters");
+ return;
+ }
if (value.contains("..")) {
errors.add("Row " + rowNum + ": '" + field + "' must not contain '..' (path traversal)");
return;
}
- if (!SAFE_IDENTIFIER.matcher(value).matches()) {
+ if (!SecretManagerServices.SAFE_IDENTIFIER.matcher(value).matches()) {
errors.add("Row " + rowNum + ": '" + field
+ "' contains invalid characters — only letters, digits, dots, hyphens, and underscores are allowed");
}
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
index 8dc3ebaa8fe..84c370f4e75 100644
--- a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
@@ -26,16 +26,19 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.apache.ofbiz.base.component.ComponentConfig;
import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
-import org.apache.ofbiz.base.secret.SecretValueResolver;
import org.apache.ofbiz.base.location.FlexibleLocation;
+import org.apache.ofbiz.base.secret.SecretProviderFactory;
+import org.apache.ofbiz.base.secret.SecretValueResolver;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilDateTime;
import org.apache.ofbiz.base.util.UtilValidate;
import org.apache.ofbiz.base.util.cache.UtilCache;
import org.apache.ofbiz.entity.Delegator;
@@ -59,22 +62,210 @@ public final class SecretManagerServices {
private static final String PASSWORDS_FILE_LOCATION = "component://base/config/passwords.properties";
private static final String JDBC_PASSWORD_PREFIX = "jdbc-password.";
+ /** Maximum length for lookupKey and related identifier fields written to properties files or logs. */
+ static final int MAX_KEY_LENGTH = 256;
+ /** Maximum length for a secret value; guards against oversized inputs that could exhaust memory or storage. */
+ static final int MAX_SECRET_VALUE_LENGTH = 8192;
/** Allows only letters, digits, dots, hyphens, underscores — blocks marker wrappers and path traversal. */
- private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[\\w.\\-]+$");
+ static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[\\w.\\-]+$");
private SecretManagerServices() { }
+ /**
+ * Tests connectivity to the active {@link org.apache.ofbiz.base.secret.SecretProvider} by
+ * attempting to resolve {@code testKey}. Reports three outcomes:
+ *
+ *
Connected — key found: the provider returned a non-empty value.
+ *
Connected — key not found: the provider responded normally but the key does
+ * not exist (confirms vault connectivity even when the test key is wrong).
+ *
Connection failed: the provider threw an SDK or network error.
+ *
+ */
+ public static Map testSecretProviderConnection(DispatchContext dctx, Map context) {
+ String testKey = (String) context.get("testKey");
+ if (UtilValidate.isEmpty(testKey)) {
+ return ServiceUtil.returnError("testKey is required");
+ }
+ testKey = testKey.trim();
+ if (testKey.length() > MAX_KEY_LENGTH) {
+ return ServiceUtil.returnError("testKey must not exceed " + MAX_KEY_LENGTH + " characters");
+ }
+ if (!SAFE_IDENTIFIER.matcher(testKey).matches()) {
+ return ServiceUtil.returnError("testKey must contain only letters, digits, dots, hyphens, and underscores");
+ }
+ GenericValue userLogin = (GenericValue) context.get("userLogin");
+ String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown";
+ try {
+ String value = SecretProviderFactory.getInstance().getSecret(testKey);
+ String outcome = UtilValidate.isNotEmpty(value) ? "found" : "empty-value";
+ Debug.logInfo("[SECRET_AUDIT] user=" + userLoginId + " action=testSecretProviderConnection"
+ + " testKey=" + testKey + " outcome=" + outcome, MODULE);
+ if (UtilValidate.isNotEmpty(value)) {
+ return ServiceUtil.returnSuccess("Connected — key '" + testKey + "' found successfully");
+ }
+ return ServiceUtil.returnSuccess("Connected — key '" + testKey + "' returned an empty value");
+ } catch (GeneralException e) {
+ String msg = e.getMessage();
+ // "not found" responses confirm connectivity; only network/auth errors are real failures
+ if (msg != null && (msg.contains("not found") || msg.contains("NotFound") || msg.contains("does not exist"))) {
+ Debug.logInfo("[SECRET_AUDIT] user=" + userLoginId + " action=testSecretProviderConnection"
+ + " testKey=" + testKey + " outcome=key-not-found", MODULE);
+ return ServiceUtil.returnSuccess("Connected — key '" + testKey + "' was not found in the provider (vault is reachable)");
+ }
+ // Log the full message server-side but never expose SDK internals to the browser:
+ // vault error messages can contain endpoint URLs, IAM ARNs, or credential fragments.
+ Debug.logWarning("[SECRET_AUDIT] user=" + userLoginId + " action=testSecretProviderConnection"
+ + " testKey=" + testKey + " outcome=connection-failed detail=" + msg, MODULE);
+ return ServiceUtil.returnError("Connection failed (" + e.getClass().getSimpleName()
+ + ") — check server logs for details");
+ }
+ }
+
+ /**
+ * Resets in-memory secret usage counters (hit/miss counts) to zero. Useful after a secret
+ * rotation so operators can observe clean post-rotation metrics without a JVM restart.
+ */
+ public static Map resetSecretUsageStats(DispatchContext dctx, Map context) {
+ GenericValue userLogin = (GenericValue) context.get("userLogin");
+ String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown";
+ SecretValueResolver.resetUsageStats();
+ Debug.logInfo("[SECRET_AUDIT] user=" + userLoginId + " action=resetSecretUsageStats", MODULE);
+ return ServiceUtil.returnSuccess("Secret usage statistics reset successfully");
+ }
+
+ /**
+ * Returns in-memory usage statistics from {@link SecretValueResolver}: aggregate hit/miss
+ * totals and a per-key breakdown. The data covers the current JVM lifetime only and resets
+ * on restart; it is meant for operator visibility, not durable monitoring.
+ */
+ public static Map getSecretUsageStats(DispatchContext dctx, Map context) {
+ Map result = ServiceUtil.returnSuccess();
+ result.put("usageSummary", SecretValueResolver.getUsageSummary());
+ result.put("usageReport", SecretValueResolver.getUsageReport());
+ return result;
+ }
+
+ /**
+ * Flushes all in-memory cached secret values so the next lookup re-fetches from the provider.
+ * Useful after a secret rotation so the new value is picked up immediately without a restart.
+ *
+ *
This clears both cache layers: the {@link SecretValueResolver} TTL cache
+ * (and the {@code UtilProperties} file cache it sits behind) and the active
+ * {@link org.apache.ofbiz.base.secret.SecretProvider}'s own internal cache via
+ * {@link SecretProviderFactory#invalidateCache()}. Clearing only the former is not sufficient:
+ * each bundled vault provider (AWS, Azure, GCP, HashiCorp Vault, Bitwarden, 1Password) keeps its
+ * own TTL-based cache (default 1 hour), so without this second call the next lookup would still
+ * return the stale value straight from the provider's cache instead of re-fetching from the vault.
+ */
+ public static Map flushSecretCache(DispatchContext dctx, Map context) {
+ GenericValue userLogin = (GenericValue) context.get("userLogin");
+ String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown";
+ SecretValueResolver.invalidateAll();
+ UtilCache.clearCachesThatStartWith("properties.UtilProperties");
+ SecretProviderFactory.invalidateCache();
+ Debug.logInfo("[SECRET_AUDIT] user=" + userLoginId + " action=flushSecretCache", MODULE);
+ return ServiceUtil.returnSuccess("Secret value cache flushed successfully");
+ }
+
+ /**
+ * Re-runs {@link SecretProviderFactory} discovery, replacing the active provider instance.
+ * Useful after deploying a new vault plugin jar or editing that plugin's own connection
+ * settings (e.g. a rotated AWS access key, a new HashiCorp AppRole secret_id) in its
+ * {@code config/*.properties} file, without requiring a full OFBiz restart.
+ */
+ public static Map reloadSecretProvider(DispatchContext dctx, Map context) {
+ GenericValue userLogin = (GenericValue) context.get("userLogin");
+ String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown";
+ UtilCache.clearCachesThatStartWith("properties.UtilProperties");
+ SecretProviderFactory.reload();
+ String providerName = SecretProviderFactory.getProviderName();
+ Debug.logInfo("[SECRET_AUDIT] user=" + userLoginId + " action=reloadSecretProvider"
+ + " provider=" + providerName, MODULE);
+ return ServiceUtil.returnSuccess("Secret provider reloaded — active provider is now: " + providerName);
+ }
+
+ /**
+ * Pulls the current value of {@code lookupKey} from the active {@link
+ * org.apache.ofbiz.base.secret.SecretProvider} and re-encrypts it into the local fallback
+ * snapshot ({@code passwords.properties} or {@code SystemProperty.systemPropertyValue}),
+ * keeping the ENC(...) value used when the remote vault is unreachable in sync with whatever
+ * was last rotated in the vault. Delegates to {@link #storeEncryptedSecret} for the actual
+ * write, so the same validation, audit logging, and cache-invalidation logic applies.
+ *
+ *
For {@code PASSWORDS_FILE} without a {@code systemResourceId}/{@code systemPropertyId}
+ * pair (the {@code jdbc-password-lookup} case), the remote key fetched is
+ * {@code jdbc-password.}, matching what {@link
+ * org.apache.ofbiz.entity.config.model.EntityConfig#getJdbcPassword} resolves at runtime. In
+ * every other case the raw {@code lookupKey} is fetched directly.
+ */
+ public static Map syncSecretFromProvider(DispatchContext dctx, Map context) {
+ Delegator delegator = dctx.getDelegator();
+ GenericValue userLogin = (GenericValue) context.get("userLogin");
+ String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown";
+ String secretTarget = (String) context.get("secretTarget");
+ String systemResourceId = (String) context.get("systemResourceId");
+ String systemPropertyId = (String) context.get("systemPropertyId");
+ String lookupKey = (String) context.get("lookupKey");
+
+ if (UtilValidate.isEmpty(lookupKey)) {
+ return ServiceUtil.returnError("lookupKey is required to sync from the active secret provider");
+ }
+ lookupKey = lookupKey.trim();
+ if (lookupKey.length() > MAX_KEY_LENGTH) {
+ return ServiceUtil.returnError("lookupKey must not exceed " + MAX_KEY_LENGTH + " characters");
+ }
+ if (!SAFE_IDENTIFIER.matcher(lookupKey).matches()) {
+ return ServiceUtil.returnError("lookupKey must contain only letters, digits, dots, hyphens, and underscores");
+ }
+
+ boolean hasResourceId = UtilValidate.isNotEmpty(systemResourceId);
+ String providerKey = (TARGET_PASSWORDS_FILE.equals(secretTarget) && !hasResourceId)
+ ? JDBC_PASSWORD_PREFIX + lookupKey
+ : lookupKey;
+
+ String freshValue;
+ try {
+ freshValue = SecretProviderFactory.getInstance().getSecret(providerKey);
+ } catch (GeneralException e) {
+ Debug.logWarning("[SECRET_AUDIT] user=" + userLoginId + " action=syncSecretFromProvider"
+ + " providerKey=" + providerKey + " outcome=fetch-failed detail=" + e.getClass().getSimpleName(),
+ MODULE);
+ return ServiceUtil.returnError("Failed to fetch the current value from the active secret provider for key '"
+ + providerKey + "' (" + e.getClass().getSimpleName() + ") — check server logs for details");
+ }
+ if (UtilValidate.isEmpty(freshValue)) {
+ return ServiceUtil.returnError("Secret provider returned an empty value for key '" + providerKey + "'");
+ }
+
+ try {
+ storeEncryptedSecret(delegator, userLogin, secretTarget, systemResourceId, systemPropertyId, lookupKey, freshValue);
+ } catch (GeneralException e) {
+ Debug.logError(e, MODULE);
+ return ServiceUtil.returnError(e.getMessage());
+ }
+ Debug.logInfo("[SECRET_AUDIT] user=" + userLoginId + " action=syncSecretFromProvider"
+ + " providerKey=" + providerKey + " outcome=synced", MODULE);
+ return ServiceUtil.returnSuccess("Local encrypted snapshot for '" + lookupKey
+ + "' synced from the active secret provider");
+ }
+
/** Service implementation for the webtools "Encrypt Value" screen. */
public static Map createEncryptedSecret(DispatchContext dctx, Map context) {
Delegator delegator = dctx.getDelegator();
+ GenericValue userLogin = (GenericValue) context.get("userLogin");
String secretTarget = (String) context.get("secretTarget");
String systemResourceId = (String) context.get("systemResourceId");
String systemPropertyId = (String) context.get("systemPropertyId");
String lookupKey = (String) context.get("lookupKey");
String secretValue = (String) context.get("secretValue");
+ String secretValueConfirm = (String) context.get("secretValueConfirm");
+
+ if (UtilValidate.isNotEmpty(secretValueConfirm) && !secretValueConfirm.equals(secretValue)) {
+ return ServiceUtil.returnError("Secret Value and Confirm Secret Value do not match");
+ }
try {
- storeEncryptedSecret(delegator, secretTarget, systemResourceId, systemPropertyId, lookupKey, secretValue);
+ storeEncryptedSecret(delegator, userLogin, secretTarget, systemResourceId, systemPropertyId, lookupKey, secretValue);
} catch (GeneralException e) {
Debug.logError(e, MODULE);
return ServiceUtil.returnError(e.getMessage());
@@ -86,15 +277,39 @@ public static Map createEncryptedSecret(DispatchContext dctx, Ma
* Encrypts {@code secretValue} and stores it as configured by {@code secretTarget}. Used by
* both the single-entry service ({@link #createEncryptedSecret}) and the CSV bulk-upload
* event so that both paths share the exact same validation and storage logic.
+ *
+ *
A structured audit log entry is written on every successful call so that the
+ * who/what/when of each secret operation is preserved for compliance review.
*/
- public static void storeEncryptedSecret(Delegator delegator, String secretTarget, String systemResourceId,
- String systemPropertyId, String lookupKey, String secretValue) throws GeneralException {
- if (UtilValidate.isEmpty(secretValue)) {
- throw new GeneralException("secretValue is required");
+ public static void storeEncryptedSecret(Delegator delegator, GenericValue userLogin, String secretTarget,
+ String systemResourceId, String systemPropertyId, String lookupKey, String secretValue)
+ throws GeneralException {
+ if (UtilValidate.isEmpty(secretValue) || secretValue.trim().isEmpty()) {
+ throw new GeneralException("secretValue is required and must not be blank");
+ }
+ if (secretValue.length() > MAX_SECRET_VALUE_LENGTH) {
+ throw new GeneralException("secretValue must not exceed " + MAX_SECRET_VALUE_LENGTH + " characters");
}
if (secretValue.trim().startsWith("ENC(")) {
throw new GeneralException("secretValue must be the plain secret — do not enter an ENC(...) encrypted value");
}
+ if (UtilValidate.isNotEmpty(systemResourceId) && systemResourceId.trim().length() > MAX_KEY_LENGTH) {
+ throw new GeneralException("systemResourceId must not exceed " + MAX_KEY_LENGTH + " characters");
+ }
+ if (UtilValidate.isNotEmpty(systemResourceId) && !SAFE_IDENTIFIER.matcher(systemResourceId.trim()).matches()) {
+ throw new GeneralException(
+ "systemResourceId contains invalid characters — only letters, digits, dots, hyphens, and underscores are allowed");
+ }
+ if (UtilValidate.isNotEmpty(systemPropertyId) && systemPropertyId.trim().length() > MAX_KEY_LENGTH) {
+ throw new GeneralException("systemPropertyId must not exceed " + MAX_KEY_LENGTH + " characters");
+ }
+ if (UtilValidate.isNotEmpty(systemPropertyId) && !SAFE_IDENTIFIER.matcher(systemPropertyId.trim()).matches()) {
+ throw new GeneralException(
+ "systemPropertyId contains invalid characters — only letters, digits, dots, hyphens, and underscores are allowed");
+ }
+ if (UtilValidate.isNotEmpty(lookupKey) && lookupKey.trim().length() > MAX_KEY_LENGTH) {
+ throw new GeneralException("lookupKey must not exceed " + MAX_KEY_LENGTH + " characters");
+ }
if (UtilValidate.isNotEmpty(lookupKey) && !SAFE_IDENTIFIER.matcher(lookupKey.trim()).matches()) {
throw new GeneralException("lookupKey must contain only letters, digits, dots, hyphens, and underscores"
+ " — do not enter a " + SecretValueResolver.MARKER_NAME + "(...) marker or path separator");
@@ -112,21 +327,47 @@ public static void storeEncryptedSecret(Delegator delegator, String secretTarget
"systemResourceId and systemPropertyId must both be provided together");
}
if (hasResourceId) {
- // Case B: property-file combined write — use plain lookupKey, no jdbc-password. prefix
+ // Case B: property-file combined write — use plain lookupKey, no jdbc-password. prefix.
+ // Write to passwords.properties first; if the source-file update then fails, remove
+ // the orphaned ENC entry so the two files don't end up in an inconsistent state.
writePasswordsProperty(lookupKey, encryptedValue);
- updatePropertiesFileAndRefreshCache(systemResourceId, systemPropertyId, lookupKey);
+ try {
+ updatePropertiesFileAndRefreshCache(systemResourceId, systemPropertyId, lookupKey);
+ } catch (GeneralException e) {
+ removePasswordsEntry(lookupKey);
+ throw e;
+ }
+ // Invalidate the resolved-value cache so the new secret is visible immediately.
+ SecretValueResolver.invalidate(lookupKey);
} else {
- // Case A: entityengine.xml jdbc-password-lookup — keep the jdbc-password. prefix
+ // Case A: entityengine.xml jdbc-password-lookup — keep the jdbc-password. prefix.
+ // Clear both the UtilProperties file cache and the SecretValueResolver TTL cache so
+ // FileBasedSecretProvider picks up the new ENC(...) value without a restart.
writePasswordsProperty(JDBC_PASSWORD_PREFIX + lookupKey, encryptedValue);
+ UtilCache.clearCachesThatStartWith("properties.UtilProperties");
+ SecretValueResolver.invalidate(JDBC_PASSWORD_PREFIX + lookupKey);
}
} else if (TARGET_SYSTEM_PROPERTY.equals(secretTarget)) {
if (UtilValidate.isEmpty(systemResourceId) || UtilValidate.isEmpty(systemPropertyId)) {
throw new GeneralException("systemResourceId and systemPropertyId are required for SystemProperty");
}
storeSystemPropertySecret(delegator, systemResourceId, systemPropertyId, lookupKey, encryptedValue);
+ // Invalidate the resolved-value cache for the lookup key so EntityUtilProperties
+ // picks up the new value immediately via SecretValueResolver.resolveKey().
+ if (UtilValidate.isNotEmpty(lookupKey)) {
+ SecretValueResolver.invalidate(lookupKey);
+ }
} else {
throw new GeneralException("Unknown secretTarget '" + secretTarget + "'");
}
+
+ String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown";
+ Debug.logInfo("[SECRET_AUDIT] user=" + userLoginId
+ + " action=storeEncryptedSecret"
+ + " target=" + secretTarget
+ + " systemResourceId=" + systemResourceId
+ + " systemPropertyId=" + systemPropertyId
+ + " lookupKey=" + lookupKey, MODULE);
}
private static void storeSystemPropertySecret(Delegator delegator, String systemResourceId, String systemPropertyId,
@@ -142,6 +383,7 @@ private static void storeSystemPropertySecret(Delegator delegator, String system
if (UtilValidate.isNotEmpty(lookupKey)) {
systemProperty.set("systemPropertyLookup", lookupKey);
}
+ systemProperty.set("lastRotatedDate", UtilDateTime.nowTimestamp());
delegator.createOrStore(systemProperty);
}
@@ -212,7 +454,7 @@ private static synchronized void removePropertiesEntry(File file, String key) th
List lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
String linePrefix = key + "=";
if (lines.removeIf(line -> line.startsWith(linePrefix))) {
- Files.write(file.toPath(), lines, StandardCharsets.UTF_8);
+ atomicWrite(file.toPath(), lines);
}
} catch (IOException e) {
throw new GeneralException("Unable to update " + file.getName() + ": " + e.getMessage(), e);
@@ -250,13 +492,24 @@ private static synchronized void writePropertiesEntry(File file, String key, Str
} else {
lines.add(newLine);
}
- Files.write(file.toPath(), lines, StandardCharsets.UTF_8);
+ atomicWrite(file.toPath(), lines);
} catch (IOException e) {
throw new GeneralException(
"Unable to update " + file.getName() + ": " + e.getMessage(), e);
}
}
+ /**
+ * Writes {@code lines} to {@code target} atomically: first to a sibling {@code .tmp} file,
+ * then renamed over the target with {@link StandardCopyOption#ATOMIC_MOVE}. A JVM crash
+ * during the write will leave the original file intact rather than producing a partial file.
+ */
+ private static void atomicWrite(Path target, List lines) throws IOException {
+ Path tmp = target.resolveSibling(target.getFileName() + ".tmp");
+ Files.write(tmp, lines, StandardCharsets.UTF_8);
+ Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ }
+
/** Updates (or appends) {@code propertyName=value} in passwords.properties, preserving all other lines. */
private static void writePasswordsProperty(String propertyName, String value) throws GeneralException {
writePropertiesEntry(getPasswordsFile(), propertyName, value);
@@ -275,9 +528,10 @@ private static File getPasswordsFile() throws GeneralException {
}
private static String getMasterKey() throws GeneralException {
- String masterKey = System.getenv("OFBIZ_MASTER_KEY");
+ String envVar = ConfigCryptoUtil.MASTER_KEY_ENV_VAR;
+ String masterKey = System.getenv(envVar);
if (UtilValidate.isEmpty(masterKey)) {
- throw new GeneralException("The OFBIZ_MASTER_KEY environment variable is not set on the server");
+ throw new GeneralException("The " + envVar + " environment variable is not set on the server");
}
return masterKey;
}
diff --git a/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretManagerEventsTest.java b/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretManagerEventsTest.java
new file mode 100644
index 00000000000..9d3041d24ad
--- /dev/null
+++ b/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretManagerEventsTest.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.webtools.secret;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for the CSV validation logic in {@link SecretManagerEvents}.
+ *
+ *
{@link SecretManagerEvents#validateCsvContent} is package-private so this test class,
+ * being in the same package, can call it directly without HTTP infrastructure.
+ */
+public class SecretManagerEventsTest {
+
+ private static final String HEADER = "target,systemResourceId,systemPropertyId,lookupKey,secretValue\n";
+
+ private static List validate(String csv) throws IOException {
+ return SecretManagerEvents.validateCsvContent(csv.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void happyPathReturnsNoErrors() throws IOException {
+ String csv = HEADER + "SYSTEM_PROPERTY,my-resource,my.property,my-key,mypassword\n";
+ assertTrue(validate(csv).isEmpty());
+ }
+
+ @Test
+ public void passwordsFileTargetIsAccepted() throws IOException {
+ String csv = HEADER + "PASSWORDS_FILE,,,my-jdbc-key,supersecret\n";
+ assertTrue(validate(csv).isEmpty());
+ }
+
+ @Test
+ public void unknownTargetIsRejected() throws IOException {
+ String csv = HEADER + "BAD_TARGET,res,prop,key,password\n";
+ List errors = validate(csv);
+ assertFalse("Should reject unknown target", errors.isEmpty());
+ assertTrue(errors.stream().anyMatch(e -> e.contains("unknown target")));
+ }
+
+ @Test
+ public void missingRequiredHeaderIsRejected() throws IOException {
+ String csvNoLookupKey = "target,systemResourceId,systemPropertyId,secretValue\n"
+ + "SYSTEM_PROPERTY,res,prop,password\n";
+ List errors = validate(csvNoLookupKey);
+ assertFalse("Should reject CSV missing lookupKey header", errors.isEmpty());
+ assertTrue(errors.stream().anyMatch(e -> e.contains("lookupKey")));
+ }
+
+ @Test
+ public void encPrefixInSecretValueIsRejected() throws IOException {
+ String csv = HEADER + "SYSTEM_PROPERTY,resource,property,mykey,ENC(abc123==)\n";
+ List errors = validate(csv);
+ assertFalse("Should reject ENC() value in secretValue", errors.isEmpty());
+ assertTrue(errors.stream().anyMatch(e -> e.contains("ENC(")));
+ }
+
+ @Test
+ public void pathTraversalInSystemResourceIdIsRejected() throws IOException {
+ String csv = HEADER + "SYSTEM_PROPERTY,../etc/passwd,property,key,password\n";
+ List errors = validate(csv);
+ assertFalse("Should reject path traversal in systemResourceId", errors.isEmpty());
+ }
+
+ @Test
+ public void invalidCharsInLookupKeyAreRejected() throws IOException {
+ String csv = HEADER + "SYSTEM_PROPERTY,resource,property,key with spaces,password\n";
+ List errors = validate(csv);
+ assertFalse("Should reject lookupKey with spaces", errors.isEmpty());
+ }
+
+ @Test
+ public void rowCountExceededIsRejected() throws IOException {
+ StringBuilder csv = new StringBuilder(HEADER);
+ for (int i = 0; i <= 500; i++) {
+ csv.append("SYSTEM_PROPERTY,res,prop,key").append(i).append(",password\n");
+ }
+ List errors = validate(csv.toString());
+ assertFalse("Should reject CSV with more than 500 rows", errors.isEmpty());
+ assertTrue(errors.stream().anyMatch(e -> e.contains("maximum")));
+ }
+
+ @Test
+ public void lookupKeyWithDotsAndHyphensIsAccepted() throws IOException {
+ String csv = HEADER + "PASSWORDS_FILE,,,jdbc-password.mysql-ofbiz,dbpassword\n";
+ assertTrue("Dots and hyphens in lookupKey should be valid", validate(csv).isEmpty());
+ }
+
+ @Test
+ public void identifierExceedingMaxLengthIsRejected() throws IOException {
+ String longKey = "a".repeat(257);
+ String csv = HEADER + "SYSTEM_PROPERTY,resource,property," + longKey + ",password\n";
+ List errors = validate(csv);
+ assertFalse("Should reject identifier exceeding 256 chars", errors.isEmpty());
+ assertTrue(errors.stream().anyMatch(e -> e.contains("lookupKey")));
+ }
+}
diff --git a/framework/webtools/template/secret/EncryptValue.ftl b/framework/webtools/template/secret/EncryptValue.ftl
index 64b99e9e7d6..ab5583d4692 100644
--- a/framework/webtools/template/secret/EncryptValue.ftl
+++ b/framework/webtools/template/secret/EncryptValue.ftl
@@ -17,6 +17,18 @@ specific language governing permissions and limitations
under the License.
-->
+
@@ -129,11 +149,13 @@ under the License.
var errors = [];
var lookupKeyInput = form.querySelector('input[name="lookupKey"]');
var secretValueInput = form.querySelector('input[name="secretValue"]');
+ var secretValueConfirmInput = form.querySelector('input[name="secretValueConfirm"]');
var lookupKey = lookupKeyInput.value.trim();
var secretValue = secretValueInput.value;
+ var secretValueConfirm = secretValueConfirmInput.value;
if (lookupKey && !SAFE_ID.test(lookupKey)) {
- errors.push('Lookup Key must contain only letters, digits, dots, hyphens, and underscores. Do not enter a LOOKUP(...) or similar marker — type the key name only.');
+ errors.push('Lookup Key must contain only letters, digits, dots, hyphens, and underscores. Do not enter a ${SecretValueMarker!"LOOKUP"}(...) or similar marker — type the key name only.');
lookupKeyInput.style.borderColor = '#a00';
} else {
lookupKeyInput.style.borderColor = '';
@@ -144,6 +166,12 @@ under the License.
} else {
secretValueInput.style.borderColor = '';
}
+ if (secretValue !== secretValueConfirm) {
+ errors.push('${uiLabelMap.WebtoolsSecretConfirmValueMismatch}');
+ secretValueConfirmInput.style.borderColor = '#a00';
+ } else {
+ secretValueConfirmInput.style.borderColor = '';
+ }
if (errors.length > 0) {
errDiv.innerHTML = errors.map(function (m) { return '
' + m + '
'; }).join('');
@@ -154,6 +182,19 @@ under the License.
}
});
}());
+
+// Submit-in-progress feedback: disable submit button and show "Processing..." on all forms
+(function () {
+ document.querySelectorAll('form.basic-form').forEach(function (f) {
+ f.addEventListener('submit', function () {
+ var btn = f.querySelector('input[type="submit"]');
+ if (btn && !btn.disabled) {
+ btn.disabled = true;
+ btn.value = 'Processing…';
+ }
+ });
+ });
+}());
@@ -184,3 +225,131 @@ under the License.
+
+
+
+
+ #if>
+#if>
diff --git a/framework/webtools/webapp/webtools/WEB-INF/controller.xml b/framework/webtools/webapp/webtools/WEB-INF/controller.xml
index f1f23262f95..835818e223c 100644
--- a/framework/webtools/webapp/webtools/WEB-INF/controller.xml
+++ b/framework/webtools/webapp/webtools/WEB-INF/controller.xml
@@ -483,6 +483,42 @@ under the License.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/framework/webtools/widget/Menus.xml b/framework/webtools/widget/Menus.xml
index f4deacd4858..6393c0bdbd2 100644
--- a/framework/webtools/widget/Menus.xml
+++ b/framework/webtools/widget/Menus.xml
@@ -54,6 +54,12 @@ under the License.
+
+
+
+
+
+
diff --git a/framework/webtools/widget/SecretManagerScreens.xml b/framework/webtools/widget/SecretManagerScreens.xml
index 80d7d0fe2e4..1ed7e1a6a5c 100644
--- a/framework/webtools/widget/SecretManagerScreens.xml
+++ b/framework/webtools/widget/SecretManagerScreens.xml
@@ -25,6 +25,19 @@ under the License.
+
From 4949fca88aef78929a57c4608b094e0367f091e5 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 17 Jun 2026 11:41:05 +0530
Subject: [PATCH 16/30] Fix EncryptValue screen 500 error and usage-stats
rendering
Move the inline groovy logic from screen widget to EncryptValue.groovy and reference it using the location attribute.
Also wire the getSecretUsageStats OUT parameters usageSummary and usageReport into the screen context. Request attributes are available only under context.parameters, so the FTL check usageSummary?has_content was always evaluating to false.
---
.../ofbiz/webtools/secret/EncryptValue.groovy | 24 +++++++++++++++++++
.../webtools/widget/SecretManagerScreens.xml | 14 +----------
2 files changed, 25 insertions(+), 13 deletions(-)
create mode 100644 framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy
diff --git a/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy
new file mode 100644
index 00000000000..5237e9981cf
--- /dev/null
+++ b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy
@@ -0,0 +1,24 @@
+import org.apache.ofbiz.base.secret.SecretProviderFactory
+import org.apache.ofbiz.base.secret.SecretValueResolver
+import org.apache.ofbiz.base.util.UtilProperties
+
+context.SecretValueMarker = SecretValueResolver.MARKER_NAME
+context.activeSecretProvider = SecretProviderFactory.getProviderName()
+def props = UtilProperties.getProperties("security")
+context.activeSettings = [
+ "Lookup marker" : props?.getProperty("secret.value.marker", "LOOKUP"),
+ "Cache TTL (seconds)": props?.getProperty("secret.cache.ttl.seconds", "300"),
+ "Retry count" : props?.getProperty("secret.provider.retry.count", "2"),
+ "Retry delay (ms)" : props?.getProperty("secret.provider.retry.delay.ms", "500"),
+ "Master key env var" : props?.getProperty("secret.master.key.env.var", "OFBIZ_MASTER_KEY"),
+ "PBKDF2 iterations" : props?.getProperty("secret.pbkdf2.iterations", "310000")
+]
+
+// getSecretUsageStats stores its OUT params as request attributes; pull them into the
+// screen context since populateBasicContext() only exposes request attrs under "parameters".
+if (parameters.usageSummary) {
+ context.usageSummary = parameters.usageSummary
+}
+if (parameters.usageReport) {
+ context.usageReport = parameters.usageReport
+}
diff --git a/framework/webtools/widget/SecretManagerScreens.xml b/framework/webtools/widget/SecretManagerScreens.xml
index 1ed7e1a6a5c..46ae0672118 100644
--- a/framework/webtools/widget/SecretManagerScreens.xml
+++ b/framework/webtools/widget/SecretManagerScreens.xml
@@ -25,19 +25,7 @@ under the License.
-
+
From 5477136bd1d0efbbfd7e0103bc422bd477947e07 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 17 Jun 2026 12:00:09 +0530
Subject: [PATCH 17/30] Fixing formatting issues.
---
.../ofbiz/webtools/secret/EncryptValue.groovy | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy
index 5237e9981cf..1f796c235e0 100644
--- a/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy
+++ b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy
@@ -4,14 +4,14 @@ import org.apache.ofbiz.base.util.UtilProperties
context.SecretValueMarker = SecretValueResolver.MARKER_NAME
context.activeSecretProvider = SecretProviderFactory.getProviderName()
-def props = UtilProperties.getProperties("security")
+Properties props = UtilProperties.getProperties('security')
context.activeSettings = [
- "Lookup marker" : props?.getProperty("secret.value.marker", "LOOKUP"),
- "Cache TTL (seconds)": props?.getProperty("secret.cache.ttl.seconds", "300"),
- "Retry count" : props?.getProperty("secret.provider.retry.count", "2"),
- "Retry delay (ms)" : props?.getProperty("secret.provider.retry.delay.ms", "500"),
- "Master key env var" : props?.getProperty("secret.master.key.env.var", "OFBIZ_MASTER_KEY"),
- "PBKDF2 iterations" : props?.getProperty("secret.pbkdf2.iterations", "310000")
+ 'Lookup marker': props?.getProperty('secret.value.marker', 'LOOKUP'),
+ 'Cache TTL (seconds)': props?.getProperty('secret.cache.ttl.seconds', '300'),
+ 'Retry count': props?.getProperty('secret.provider.retry.count', '2'),
+ 'Retry delay (ms)': props?.getProperty('secret.provider.retry.delay.ms', '500'),
+ 'Master key env var': props?.getProperty('secret.master.key.env.var', 'OFBIZ_MASTER_KEY'),
+ 'PBKDF2 iterations': props?.getProperty('secret.pbkdf2.iterations', '310000')
]
// getSecretUsageStats stores its OUT params as request attributes; pull them into the
From 9d21423fb6c99a1f26f0f329b1bdee4942f0a7e1 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 17 Jun 2026 12:04:39 +0530
Subject: [PATCH 18/30] Fixing formatting issues.
---
.../secret/FallbackSecretProviderTest.java | 32 ++++++++++++++-----
1 file changed, 24 insertions(+), 8 deletions(-)
diff --git a/framework/base/src/test/java/org/apache/ofbiz/base/secret/FallbackSecretProviderTest.java b/framework/base/src/test/java/org/apache/ofbiz/base/secret/FallbackSecretProviderTest.java
index 661f6e390e7..8ef2fdfc02e 100644
--- a/framework/base/src/test/java/org/apache/ofbiz/base/secret/FallbackSecretProviderTest.java
+++ b/framework/base/src/test/java/org/apache/ofbiz/base/secret/FallbackSecretProviderTest.java
@@ -109,15 +109,23 @@ public void closeDelegatesToFallbackEvenIfPrimaryThrows() {
AtomicBoolean fallbackClosed = new AtomicBoolean(false);
SecretProvider primary = new SecretProvider() {
@Override
- public String getSecret(String key) { return ""; }
+ public String getSecret(String key) {
+ return "";
+ }
@Override
- public void close() { throw new RuntimeException("primary-close-error"); }
+ public void close() {
+ throw new RuntimeException("primary-close-error");
+ }
};
SecretProvider fallback = new SecretProvider() {
@Override
- public String getSecret(String key) { return ""; }
+ public String getSecret(String key) {
+ return "";
+ }
@Override
- public void close() { fallbackClosed.set(true); }
+ public void close() {
+ fallbackClosed.set(true);
+ }
};
try {
new FallbackSecretProvider(primary, fallback).close();
@@ -130,18 +138,26 @@ public void closeDelegatesToFallbackEvenIfPrimaryThrows() {
private static SecretProvider fixedProvider(String value, boolean fallbackEnabled) {
return new SecretProvider() {
@Override
- public String getSecret(String key) { return value; }
+ public String getSecret(String key) {
+ return value;
+ }
@Override
- public boolean isFallbackEnabled() { return fallbackEnabled; }
+ public boolean isFallbackEnabled() {
+ return fallbackEnabled;
+ }
};
}
private static SecretProvider throwingProvider(GeneralException ex, boolean fallbackEnabled) {
return new SecretProvider() {
@Override
- public String getSecret(String key) throws GeneralException { throw ex; }
+ public String getSecret(String key) throws GeneralException {
+ throw ex;
+ }
@Override
- public boolean isFallbackEnabled() { return fallbackEnabled; }
+ public boolean isFallbackEnabled() {
+ return fallbackEnabled;
+ }
};
}
}
From 5717a3faf5ebb89f9fea56a04c9af86036205045 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 17 Jun 2026 12:59:11 +0530
Subject: [PATCH 19/30] Fixing console errors for FTL file.
getClass For "${...}" content: Expected a string or something automatically convertible to string (number, date or boolean), or
"template output" , but this has evaluated to a method+sequence (wrapper: f.e.b.SimpleMethodModel): ==> activeSettings[k] [in template
"component://webtools/template/secret/EncryptValue.ftl" at line 26, column 76] ---- Tip: Maybe using obj.something instead of
obj.getSomething will yield the desired value. ---- ---- FTL stack trace ("~" means nesting-related): - Failed at: ${activeSettings[k]}
[in template "component://webtools/template/secret/EncryptValue.ftl" at line 26, column 74] ----
getOrDefault For "${...}" content: Expected a string or something automatically convertible to string (number, date or boolean), or
"template output" , but this has evaluated to a method+sequence (wrapper: f.e.b.SimpleMethodModel): ==> activeSettings[k] [in template
"component://webtools/template/secret/EncryptValue.ftl" at line 26, column 76] ---- Tip: Maybe using obj.something(params) instead of
obj.something will yield the desired value ---- ---- FTL stack trace ("~" means nesting-related): - Failed at: ${activeSettings[k]} [in
template "component://webtools/template/secret/EncryptValue.ftl" at line 26, column 74] ----
---
.../ofbiz/webtools/secret/EncryptValue.groovy | 22 +++++++++++++------
.../webtools/template/secret/EncryptValue.ftl | 17 +++++++-------
2 files changed, 23 insertions(+), 16 deletions(-)
diff --git a/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy
index 1f796c235e0..d3d1a1f7cef 100644
--- a/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy
+++ b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy
@@ -5,13 +5,17 @@ import org.apache.ofbiz.base.util.UtilProperties
context.SecretValueMarker = SecretValueResolver.MARKER_NAME
context.activeSecretProvider = SecretProviderFactory.getProviderName()
Properties props = UtilProperties.getProperties('security')
+// A List of [label, value] pairs, not a Map: FreeMarker's BeansWrapper exposes java.util.Map's
+// own methods (getClass, keySet, replace, ...) as extra "keys" alongside real entries when a
+// Map is iterated with ?keys/[k], so the template renders garbage for those bogus pseudo-keys.
+// A List is wrapped as a plain sequence with no such method-name leakage.
context.activeSettings = [
- 'Lookup marker': props?.getProperty('secret.value.marker', 'LOOKUP'),
- 'Cache TTL (seconds)': props?.getProperty('secret.cache.ttl.seconds', '300'),
- 'Retry count': props?.getProperty('secret.provider.retry.count', '2'),
- 'Retry delay (ms)': props?.getProperty('secret.provider.retry.delay.ms', '500'),
- 'Master key env var': props?.getProperty('secret.master.key.env.var', 'OFBIZ_MASTER_KEY'),
- 'PBKDF2 iterations': props?.getProperty('secret.pbkdf2.iterations', '310000')
+ ['Lookup marker', props?.getProperty('secret.value.marker', 'LOOKUP')],
+ ['Cache TTL (seconds)', props?.getProperty('secret.cache.ttl.seconds', '300')],
+ ['Retry count', props?.getProperty('secret.provider.retry.count', '2')],
+ ['Retry delay (ms)', props?.getProperty('secret.provider.retry.delay.ms', '500')],
+ ['Master key env var', props?.getProperty('secret.master.key.env.var', 'OFBIZ_MASTER_KEY')],
+ ['PBKDF2 iterations', props?.getProperty('secret.pbkdf2.iterations', '310000')]
]
// getSecretUsageStats stores its OUT params as request attributes; pull them into the
@@ -20,5 +24,9 @@ if (parameters.usageSummary) {
context.usageSummary = parameters.usageSummary
}
if (parameters.usageReport) {
- context.usageReport = parameters.usageReport
+ // Same Map-iteration pitfall as activeSettings above: flatten to a List of rows before
+ // handing it to the template instead of iterating the Map with ?keys/[key].
+ context.usageReportRows = parameters.usageReport.collect { key, stats ->
+ [key, stats.hits ?: 0, stats.misses ?: 0, stats.total ?: 0]
+ }
}
diff --git a/framework/webtools/template/secret/EncryptValue.ftl b/framework/webtools/template/secret/EncryptValue.ftl
index ab5583d4692..48b964dddaf 100644
--- a/framework/webtools/template/secret/EncryptValue.ftl
+++ b/framework/webtools/template/secret/EncryptValue.ftl
@@ -22,8 +22,8 @@ under the License.
Active Configuration
- <#list activeSettings?keys as k>
-
${k}
${activeSettings[k]}
+ <#list activeSettings as entry>
+
${entry[0]}
${entry[1]}
#list>
@@ -329,7 +329,7 @@ under the License.
${uiLabelMap.WebtoolsSecretUsageTotalLookups}: ${usageSummary.totalLookups!0} |
${uiLabelMap.WebtoolsSecretUsageCachedKeys}: ${usageSummary.cachedKeys!0}
- <#if usageReport?has_content>
+ <#if usageReportRows?has_content>
@@ -340,13 +340,12 @@ under the License.
- <#list usageReport?keys as key>
- <#assign stats = usageReport[key]/>
+ <#list usageReportRows as row>
-
${key}
-
${stats.hits!0}
-
${stats.misses!0}
-
${stats.total!0}
+
${row[0]}
+
${row[1]}
+
${row[2]}
+
${row[3]}
#list>
From 6da29791891a408d533e541e392df415361b7b51 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Mon, 22 Jun 2026 22:07:59 +0530
Subject: [PATCH 20/30] Add automatic secret-rotation sync for
SystemProperty-backed secrets
Adds an hourly JobSandbox job (autoSyncRotatedSecrets, disabled by default via secret.rotation.autosync.enabled) that detects secrets rotated in the remote vault and writes the new value into SystemProperty.systemPropertyValue, without an admin manually clicking "Sync Now" in EncryptValue. Also adds change-detection to syncSecretFromProvider so lastRotatedDate only advances
on an actual value change, not on every poll.
Limitations: sync is one-way (vault -> OFBiz) only; no SecretProvider writes back to the vault, so a value edited locally for a vault-backed SystemProperty row is overwritten on the next run. Scope is SystemProperty only -
passwords.properties-backed secrets (including jdbc-password-lookup DB passwords, explicitly out of scope) remain manual-sync-only via EncryptValue
or the generateEncryptedSecret/generateDBPassword Gradle tasks.
---
framework/security/config/security.properties | 9 ++
.../SecretManagerScheduledServiceData.xml | 30 ++++
framework/webtools/ofbiz-component.xml | 1 +
framework/webtools/servicedef/services.xml | 12 ++
.../secret/SecretManagerServices.java | 125 +++++++++++++++-
.../secret/SecretManagerServicesTest.java | 137 ++++++++++++++++++
6 files changed, 313 insertions(+), 1 deletion(-)
create mode 100644 framework/webtools/data/SecretManagerScheduledServiceData.xml
create mode 100644 framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretManagerServicesTest.java
diff --git a/framework/security/config/security.properties b/framework/security/config/security.properties
index 0d5b1cbf761..4fd54a1df48 100644
--- a/framework/security/config/security.properties
+++ b/framework/security/config/security.properties
@@ -437,6 +437,15 @@ secret.cache.ttl.seconds=300
# -- triggers "Flush Secret Cache" in the admin screen. Disabled (0) by default — most installs rely on
# -- the per-provider cache.ttl.seconds expiring on its own, or an admin/automation explicitly flushing.
secret.rotation.poll.seconds=0
+# -- When true, a scheduled job (JobSandbox "SECRET_AUTOSYNC", hourly by default) automatically
+# -- calls syncSecretFromProvider for every SystemProperty row with a non-empty systemPropertyLookup,
+# -- so rotated secrets are written back to SystemProperty.systemPropertyValue without an admin manually
+# -- clicking "Sync Now". Disabled (false) by default. passwords.properties-only secrets are not covered
+# -- by this job and must still be synced manually via the EncryptValue admin screen.
+# -- CAVEAT: sync is one-way (vault -> OFBiz only); no SecretProvider implementation writes back to
+# -- the remote vault. A value edited locally via EncryptValue for a vault-backed SystemProperty row
+# -- will be overwritten on the next run if it differs from the vault — the vault is the source of truth.
+secret.rotation.autosync.enabled=false
# -- Number of times to retry the primary SecretProvider before falling back to passwords.properties.
# -- Set to 0 to disable retries (fall back immediately on first failure).
secret.provider.retry.count=2
diff --git a/framework/webtools/data/SecretManagerScheduledServiceData.xml b/framework/webtools/data/SecretManagerScheduledServiceData.xml
new file mode 100644
index 00000000000..6cca1cc28b8
--- /dev/null
+++ b/framework/webtools/data/SecretManagerScheduledServiceData.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
diff --git a/framework/webtools/ofbiz-component.xml b/framework/webtools/ofbiz-component.xml
index 9a073e129ae..4168962e257 100644
--- a/framework/webtools/ofbiz-component.xml
+++ b/framework/webtools/ofbiz-component.xml
@@ -25,6 +25,7 @@ under the License.
+
+
+
+
+ Scheduled-job entry point (see JobSandbox seed data "SECRET_AUTOSYNC") that calls
+ syncSecretFromProvider for every SystemProperty row with a non-empty systemPropertyLookup, so a secret
+ rotated in the remote vault is written back automatically without an admin clicking "Sync Now". No-ops
+ unless secret.rotation.autosync.enabled=true in security.properties (disabled by default).
+
+
+
+
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
index 84c370f4e75..024f9899eec 100644
--- a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java
@@ -39,12 +39,17 @@
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilDateTime;
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.apache.ofbiz.base.util.UtilProperties;
import org.apache.ofbiz.base.util.UtilValidate;
import org.apache.ofbiz.base.util.cache.UtilCache;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.condition.EntityCondition;
+import org.apache.ofbiz.entity.condition.EntityOperator;
import org.apache.ofbiz.entity.util.EntityQuery;
import org.apache.ofbiz.service.DispatchContext;
+import org.apache.ofbiz.service.LocalDispatcher;
import org.apache.ofbiz.service.ServiceUtil;
/**
@@ -238,6 +243,15 @@ public static Map syncSecretFromProvider(DispatchContext dctx, M
}
try {
+ String currentValue = readCurrentStoredValue(delegator, secretTarget, systemResourceId, systemPropertyId, providerKey);
+ if (currentValue != null && currentValue.equals(freshValue)) {
+ Debug.logInfo("[SECRET_AUDIT] user=" + userLoginId + " action=syncSecretFromProvider"
+ + " providerKey=" + providerKey + " outcome=no-change", MODULE);
+ Map result = ServiceUtil.returnSuccess("Local encrypted snapshot for '" + lookupKey
+ + "' is already up to date — no change detected from the active secret provider");
+ result.put("changed", Boolean.FALSE);
+ return result;
+ }
storeEncryptedSecret(delegator, userLogin, secretTarget, systemResourceId, systemPropertyId, lookupKey, freshValue);
} catch (GeneralException e) {
Debug.logError(e, MODULE);
@@ -245,8 +259,117 @@ public static Map syncSecretFromProvider(DispatchContext dctx, M
}
Debug.logInfo("[SECRET_AUDIT] user=" + userLoginId + " action=syncSecretFromProvider"
+ " providerKey=" + providerKey + " outcome=synced", MODULE);
- return ServiceUtil.returnSuccess("Local encrypted snapshot for '" + lookupKey
+ Map result = ServiceUtil.returnSuccess("Local encrypted snapshot for '" + lookupKey
+ "' synced from the active secret provider");
+ result.put("changed", Boolean.TRUE);
+ return result;
+ }
+
+ /**
+ * Returns the current plaintext value already stored locally for {@code providerKey}, or
+ * {@code null} if there is nothing stored yet (e.g. first-ever sync). Used by {@link
+ * #syncSecretFromProvider} to skip re-encrypting and rewriting when the vault value hasn't
+ * actually changed, so {@code lastRotatedDate} only advances on a real rotation.
+ */
+ static String readCurrentStoredValue(Delegator delegator, String secretTarget,
+ String systemResourceId, String systemPropertyId, String providerKey) throws GeneralException {
+ if (TARGET_SYSTEM_PROPERTY.equals(secretTarget)) {
+ GenericValue systemProperty = EntityQuery.use(delegator).from("SystemProperty")
+ .where("systemResourceId", systemResourceId, "systemPropertyId", systemPropertyId)
+ .queryOne();
+ if (systemProperty == null) {
+ return null;
+ }
+ String storedValue = systemProperty.getString("systemPropertyValue");
+ return UtilValidate.isEmpty(storedValue) ? null : ConfigCryptoUtil.decryptIfEncrypted(storedValue, providerKey);
+ } else if (TARGET_PASSWORDS_FILE.equals(secretTarget)) {
+ String storedValue = UtilProperties.getPropertyValue("passwords", providerKey);
+ return UtilValidate.isEmpty(storedValue) ? null : ConfigCryptoUtil.decryptIfEncrypted(storedValue, providerKey);
+ }
+ return null;
+ }
+
+ /**
+ * Scheduled-job entry point (see {@code JobSandbox} seed data "SECRET_AUTOSYNC") that
+ * automatically calls {@link #syncSecretFromProvider} for every {@code SystemProperty} row that
+ * has a non-empty {@code systemPropertyLookup}, so a secret rotated in the remote vault is
+ * written back to {@code SystemProperty.systemPropertyValue} without an admin manually clicking
+ * "Sync Now" in the {@code EncryptValue} screen.
+ *
+ *
No-ops when {@code secret.rotation.autosync.enabled} (security.properties) is not
+ * {@code true} — disabled by default. A failure resolving one key is logged and counted, but
+ * does not prevent the remaining keys from being processed.
+ */
+ public static Map autoSyncRotatedSecrets(DispatchContext dctx, Map context) {
+ if (!UtilProperties.getPropertyAsBoolean("security", "secret.rotation.autosync.enabled", false)) {
+ return ServiceUtil.returnSuccess(
+ "Automatic secret rotation sync is disabled (secret.rotation.autosync.enabled=false)");
+ }
+ GenericValue userLogin = (GenericValue) context.get("userLogin");
+ return syncAllRotatedSystemProperties(dctx, userLogin);
+ }
+
+ /**
+ * Queries every {@code SystemProperty} row with a non-empty {@code systemPropertyLookup} and
+ * calls {@link #syncSecretFromProvider} for each, isolating per-row failures. Split out from
+ * {@link #autoSyncRotatedSecrets} so the looping/counting logic can be unit-tested without
+ * depending on the {@code secret.rotation.autosync.enabled} flag.
+ */
+ static Map syncAllRotatedSystemProperties(DispatchContext dctx, GenericValue userLogin) {
+ Delegator delegator = dctx.getDelegator();
+ LocalDispatcher dispatcher = dctx.getDispatcher();
+
+ long syncedCount = 0;
+ long unchangedCount = 0;
+ long failedCount = 0;
+ try {
+ List lookupBackedProperties = EntityQuery.use(delegator).from("SystemProperty")
+ .where(EntityCondition.makeCondition("systemPropertyLookup", EntityOperator.NOT_EQUAL, null))
+ .queryList();
+ for (GenericValue systemProperty : lookupBackedProperties) {
+ String systemResourceId = systemProperty.getString("systemResourceId");
+ String systemPropertyId = systemProperty.getString("systemPropertyId");
+ String lookupKey = systemProperty.getString("systemPropertyLookup");
+ if (UtilValidate.isEmpty(lookupKey)) {
+ continue;
+ }
+ try {
+ Map syncResult = dispatcher.runSync("syncSecretFromProvider", UtilMisc.toMap(
+ "secretTarget", TARGET_SYSTEM_PROPERTY,
+ "systemResourceId", systemResourceId,
+ "systemPropertyId", systemPropertyId,
+ "lookupKey", lookupKey,
+ "userLogin", userLogin));
+ if (ServiceUtil.isError(syncResult)) {
+ failedCount++;
+ Debug.logWarning("[SECRET_AUDIT] action=autoSyncRotatedSecrets systemResourceId=" + systemResourceId
+ + " systemPropertyId=" + systemPropertyId + " outcome=failed detail="
+ + ServiceUtil.getErrorMessage(syncResult), MODULE);
+ } else if (Boolean.TRUE.equals(syncResult.get("changed"))) {
+ syncedCount++;
+ } else {
+ unchangedCount++;
+ }
+ } catch (GeneralException e) {
+ failedCount++;
+ Debug.logWarning("[SECRET_AUDIT] action=autoSyncRotatedSecrets systemResourceId=" + systemResourceId
+ + " systemPropertyId=" + systemPropertyId + " outcome=failed detail="
+ + e.getClass().getSimpleName(), MODULE);
+ }
+ }
+ } catch (GeneralException e) {
+ Debug.logError(e, MODULE);
+ return ServiceUtil.returnError("Unable to query SystemProperty rows for rotation sync: " + e.getMessage());
+ }
+
+ Debug.logInfo("[SECRET_AUDIT] action=autoSyncRotatedSecrets synced=" + syncedCount
+ + " unchanged=" + unchangedCount + " failed=" + failedCount, MODULE);
+ Map result = ServiceUtil.returnSuccess("Automatic secret rotation sync complete: "
+ + syncedCount + " synced, " + unchangedCount + " unchanged, " + failedCount + " failed");
+ result.put("syncedCount", syncedCount);
+ result.put("unchangedCount", unchangedCount);
+ result.put("failedCount", failedCount);
+ return result;
}
/** Service implementation for the webtools "Encrypt Value" screen. */
diff --git a/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretManagerServicesTest.java b/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretManagerServicesTest.java
new file mode 100644
index 00000000000..2aef8b876c5
--- /dev/null
+++ b/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretManagerServicesTest.java
@@ -0,0 +1,137 @@
+/*******************************************************************************
+ * 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.webtools.secret;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.condition.EntityCondition;
+import org.apache.ofbiz.entity.util.EntityFindOptions;
+import org.apache.ofbiz.service.DispatchContext;
+import org.apache.ofbiz.service.GenericServiceException;
+import org.apache.ofbiz.service.LocalDispatcher;
+import org.apache.ofbiz.service.ServiceUtil;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+/**
+ * Unit tests for the rotation-sync logic in {@link SecretManagerServices}: the change-detection
+ * step in {@code readCurrentStoredValue} and the {@code syncAllRotatedSystemProperties} loop
+ * backing {@code autoSyncRotatedSecrets}. These mock {@link Delegator}/{@link DispatchContext}/
+ * {@link LocalDispatcher} so they run without booting the full entity/service engine.
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class SecretManagerServicesTest {
+
+ @Mock
+ private Delegator delegator;
+ @Mock
+ private DispatchContext dctx;
+ @Mock
+ private LocalDispatcher dispatcher;
+
+ private static GenericValue systemPropertyRow(String resourceId, String propertyId, String lookupKey, String value) {
+ GenericValue gv = mock(GenericValue.class);
+ when(gv.getString("systemResourceId")).thenReturn(resourceId);
+ when(gv.getString("systemPropertyId")).thenReturn(propertyId);
+ when(gv.getString("systemPropertyLookup")).thenReturn(lookupKey);
+ when(gv.getString("systemPropertyValue")).thenReturn(value);
+ return gv;
+ }
+
+ private void mockSystemPropertyFindList(List rows) throws GenericEntityException {
+ // EntityQuery.use(Delegator) routes through DelegatorProvider.getDelegator(), which an
+ // un-stubbed mock would otherwise answer with null.
+ when(delegator.getDelegator()).thenReturn(delegator);
+ when(delegator.findList(eq("SystemProperty"), any(EntityCondition.class), any(), any(), any(),
+ any(EntityFindOptions.class), anyBoolean())).thenReturn(rows);
+ }
+
+ @Test
+ public void autoSyncNoOpsWhenDisabled() {
+ // secret.rotation.autosync.enabled defaults to false in security.properties, so the
+ // delegator/dispatcher (both unset mocks here) are never touched.
+ Map result = SecretManagerServices.autoSyncRotatedSecrets(dctx, UtilMisc.toMap());
+ assertEquals("success", result.get("responseMessage"));
+ }
+
+ @Test
+ public void readCurrentStoredValueReturnsNullWhenRowMissing() throws GenericEntityException, GeneralException {
+ mockSystemPropertyFindList(List.of());
+ String value = SecretManagerServices.readCurrentStoredValue(
+ delegator, SecretManagerServices.TARGET_SYSTEM_PROPERTY, "myResource", "myProp", "myLookupKey");
+ assertNull(value);
+ }
+
+ @Test
+ public void readCurrentStoredValueReturnsPlainStoredValue() throws GenericEntityException, GeneralException {
+ GenericValue row = systemPropertyRow("myResource", "myProp", "myLookupKey", "currentPlainValue");
+ mockSystemPropertyFindList(List.of(row));
+ String value = SecretManagerServices.readCurrentStoredValue(
+ delegator, SecretManagerServices.TARGET_SYSTEM_PROPERTY, "myResource", "myProp", "myLookupKey");
+ assertEquals("currentPlainValue", value);
+ }
+
+ @Test
+ public void autoSyncCountsChangedUnchangedAndFailedRowsIndependently() throws GenericEntityException, GenericServiceException {
+ GenericValue changedRow = systemPropertyRow("resA", "propA", "keyA", "old-value");
+ GenericValue unchangedRow = systemPropertyRow("resB", "propB", "keyB", "same-value");
+ GenericValue failingRow = systemPropertyRow("resC", "propC", "keyC", "whatever");
+ mockSystemPropertyFindList(List.of(changedRow, unchangedRow, failingRow));
+ when(dctx.getDelegator()).thenReturn(delegator);
+ when(dctx.getDispatcher()).thenReturn(dispatcher);
+
+ when(dispatcher.runSync(eq("syncSecretFromProvider"), lookupKeyMatches("keyA"))).thenReturn(successWithChanged(true));
+ when(dispatcher.runSync(eq("syncSecretFromProvider"), lookupKeyMatches("keyB"))).thenReturn(successWithChanged(false));
+ when(dispatcher.runSync(eq("syncSecretFromProvider"), lookupKeyMatches("keyC")))
+ .thenThrow(new GenericServiceException("vault unreachable"));
+
+ Map result = SecretManagerServices.syncAllRotatedSystemProperties(dctx, null);
+
+ assertEquals(1L, result.get("syncedCount"));
+ assertEquals(1L, result.get("unchangedCount"));
+ assertEquals(1L, result.get("failedCount"));
+ }
+
+ private static Map lookupKeyMatches(String lookupKey) {
+ return argThat(map -> map != null && lookupKey.equals(map.get("lookupKey")));
+ }
+
+ private static Map successWithChanged(boolean changed) {
+ Map result = ServiceUtil.returnSuccess();
+ result.put("changed", changed);
+ return result;
+ }
+}
From d9152f09c738f85f17b232f7c59637e0c3625962 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Thu, 25 Jun 2026 13:24:36 +0530
Subject: [PATCH 21/30] 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.
---
.../ofbiz/base/secret/SecretProviderUtil.java | 152 +++++++++++++++++
.../secret/SecretProviderHelpersTest.java | 161 ++++++++++++++++++
.../resources/secrethelperstest.properties | 27 +++
3 files changed, 340 insertions(+)
create mode 100644 framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderUtil.java
create mode 100644 framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretProviderHelpersTest.java
create mode 100644 framework/base/src/test/resources/secrethelperstest.properties
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderUtil.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderUtil.java
new file mode 100644
index 00000000000..6888200ef87
--- /dev/null
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderUtil.java
@@ -0,0 +1,152 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.base.secret;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.ofbiz.base.lang.ThreadSafe;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilProperties;
+
+/**
+ * Shared helpers used by every bundled {@link SecretProvider} plugin: an in-memory TTL
+ * cache for resolved secret values, a reader for that cache's TTL property, and the
+ * {@code key.alias.*} per-key naming-override convention.
+ */
+public final class SecretProviderUtil {
+
+ private static final String KEY_ALIAS_PREFIX = "key.alias.";
+
+ private SecretProviderUtil() { }
+
+ /**
+ * Simple in-memory TTL cache, used by every bundled provider to avoid a remote API call
+ * on every {@link SecretProvider#getSecret(String)} invocation.
+ *
+ *
Each entry expires independently, based on the TTL passed to {@link #put}; there is
+ * no background eviction thread — expiry is checked lazily on {@link #get}.
+ *
+ * @param the cache key type (every bundled provider uses {@code String}, the OFBiz
+ * logical secret key)
+ * @param the cached value type (every bundled provider uses {@code String}, the
+ * resolved secret value)
+ */
+ @ThreadSafe
+ public static final class Cache {
+
+ private final ConcurrentHashMap> entries = new ConcurrentHashMap<>();
+
+ private static final class Entry {
+ private final V value;
+ private final long expiresAt;
+
+ Entry(V value, long ttlMs) {
+ this.value = value;
+ this.expiresAt = System.currentTimeMillis() + ttlMs;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+ }
+
+ /**
+ * Returns the cached value for {@code key}, or {@code null} if absent or expired.
+ * An expired entry is treated the same as a missing one; it is overwritten on the
+ * next {@link #put}, not proactively removed here.
+ */
+ public V get(K key) {
+ Entry entry = entries.get(key);
+ return (entry != null && !entry.isExpired()) ? entry.value : null;
+ }
+
+ /** Stores {@code value} under {@code key}, expiring {@code ttlMs} milliseconds from now. */
+ public void put(K key, V value, long ttlMs) {
+ entries.put(key, new Entry<>(value, ttlMs));
+ }
+
+ /** Removes every cached entry, forcing the next {@link #get} for any key to miss. */
+ public void clear() {
+ entries.clear();
+ }
+ }
+
+ /**
+ * Reads {@code propertyKey} from {@code configResource} (default {@code defaultSeconds}),
+ * returning the value in milliseconds. Falls back to {@code defaultSeconds} (and logs a
+ * warning attributed to {@code moduleName}) if the configured value isn't a valid long.
+ *
+ * @param configResource the provider plugin's own config resource name (e.g.
+ * {@code "aws-secrets-manager"})
+ * @param propertyKey the TTL property name (e.g. {@code "aws.secretsmanager.cache.ttl.seconds"})
+ * @param defaultSeconds the default TTL in seconds, used both as the property default and
+ * as the fallback on a parse failure
+ * @param moduleName the calling provider's {@code MODULE} constant, so the warning log is
+ * attributed to the right class
+ * @return the TTL in milliseconds
+ */
+ public static long readTtlMs(String configResource, String propertyKey, long defaultSeconds, String moduleName) {
+ String raw = UtilProperties.getPropertyValue(configResource, propertyKey, String.valueOf(defaultSeconds));
+ try {
+ return Long.parseLong(raw.trim()) * 1000L;
+ } catch (NumberFormatException e) {
+ Debug.logWarning("Invalid " + propertyKey + " '" + raw + "', defaulting to " + defaultSeconds + "s",
+ moduleName);
+ return defaultSeconds * 1000L;
+ }
+ }
+
+ /**
+ * Reads every {@code key.alias.=} entry from {@code configResource}
+ * and returns them as {@code logicalKey -> physicalKey}.
+ *
+ *
Each provider stores its secrets under a physical name derived from the OFBiz logical
+ * key — verbatim, or via a provider-specific convention (e.g. dot-replacement, a fixed
+ * env-var transform). When a provider's backend can't accept that physical name for a
+ * specific key (naming restrictions on {@code .}/{@code -}, length, case, a collision,
+ * etc.), an explicit {@code key.alias.=} entry in that provider's
+ * own config resource overrides just that one key. Keys with no alias entry are
+ * unaffected — this is purely additive, never required.
+ *
+ *
See "Key aliasing for provider naming restrictions" in
+ * {@code generic-secret-resolution-design.md} §6 for the full design rationale.
+ *
+ * @param configResource the provider plugin's own config resource name (e.g.
+ * {@code "aws-secrets-manager"}), as passed to
+ * {@link UtilProperties#getProperties(String)}
+ * @return an immutable-content map of logical key to physical key; empty if the
+ * resource is missing or has no {@code key.alias.*} entries
+ */
+ public static Map loadKeyAliases(String configResource) {
+ Properties props = UtilProperties.getProperties(configResource);
+ if (props == null) {
+ return Map.of();
+ }
+ Map aliases = new HashMap<>();
+ for (String name : props.stringPropertyNames()) {
+ if (name.startsWith(KEY_ALIAS_PREFIX)) {
+ aliases.put(name.substring(KEY_ALIAS_PREFIX.length()), props.getProperty(name));
+ }
+ }
+ return aliases;
+ }
+}
diff --git a/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretProviderHelpersTest.java b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretProviderHelpersTest.java
new file mode 100644
index 00000000000..434fc31d21b
--- /dev/null
+++ b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretProviderHelpersTest.java
@@ -0,0 +1,161 @@
+/*******************************************************************************
+ * 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.base.secret;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Map;
+
+import org.junit.Test;
+
+/**
+ * Tests for {@link SecretProviderUtil}, the shared {@link SecretProvider} plugin helper:
+ * the {@link SecretProviderUtil.Cache} TTL cache, {@link SecretProviderUtil#readTtlMs} and
+ * {@link SecretProviderUtil#loadKeyAliases}. The latter two share the
+ * {@code secrethelperstest.properties} fixture in {@code framework/base/src/test/resources}.
+ */
+public class SecretProviderHelpersTest {
+
+ // -- SecretProviderUtil.Cache --
+
+ @Test
+ public void ttlCacheGetReturnsNullForMissingKey() {
+ SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
+
+ assertNull(cache.get("missing"));
+ }
+
+ @Test
+ public void ttlCachePutThenGetReturnsStoredValue() {
+ SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
+
+ cache.put("key", "value", 3_600_000L);
+
+ assertEquals("value", cache.get("key"));
+ }
+
+ @Test
+ public void ttlCacheGetReturnsNullForExpiredEntry() {
+ SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
+
+ cache.put("key", "value", -1L); // expires immediately
+
+ assertNull(cache.get("key"));
+ }
+
+ @Test
+ public void ttlCachePutOverwritesExistingEntry() {
+ SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
+
+ cache.put("key", "first", 3_600_000L);
+ cache.put("key", "second", 3_600_000L);
+
+ assertEquals("second", cache.get("key"));
+ }
+
+ @Test
+ public void ttlCacheClearRemovesAllEntries() {
+ SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
+
+ cache.put("key1", "value1", 3_600_000L);
+ cache.put("key2", "value2", 3_600_000L);
+ cache.clear();
+
+ assertNull(cache.get("key1"));
+ assertNull(cache.get("key2"));
+ }
+
+ @Test
+ public void ttlCacheSupportsNonStringValueTypes() {
+ SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
+
+ cache.put("count", 42, 3_600_000L);
+
+ assertEquals(Integer.valueOf(42), cache.get("count"));
+ }
+
+ // -- SecretProviderUtil.readTtlMs --
+
+ @Test
+ public void readTtlMsReturnsConfiguredValueInMilliseconds() {
+ long ttlMs = SecretProviderUtil.readTtlMs("secrethelperstest", "test.cache.ttl.seconds", 3600,
+ "SecretProviderHelpersTest");
+
+ assertEquals(7_200_000L, ttlMs);
+ }
+
+ @Test
+ public void readTtlMsFallsBackToDefaultForMissingProperty() {
+ long ttlMs = SecretProviderUtil.readTtlMs("secrethelperstest", "test.no.such.property", 3600,
+ "SecretProviderHelpersTest");
+
+ assertEquals(3_600_000L, ttlMs);
+ }
+
+ @Test
+ public void readTtlMsFallsBackToDefaultForInvalidValue() {
+ long ttlMs = SecretProviderUtil.readTtlMs("secrethelperstest", "test.cache.ttl.invalid", 3600,
+ "SecretProviderHelpersTest");
+
+ assertEquals(3_600_000L, ttlMs);
+ }
+
+ @Test
+ public void readTtlMsFallsBackToDefaultForMissingResource() {
+ long ttlMs = SecretProviderUtil.readTtlMs("no-such-resource-xyz", "any.key", 1800, "SecretProviderHelpersTest");
+
+ assertEquals(1_800_000L, ttlMs);
+ }
+
+ // -- SecretProviderUtil.loadKeyAliases --
+
+ @Test
+ public void loadParsesKeyAliasEntries() {
+ Map aliases = SecretProviderUtil.loadKeyAliases("secrethelperstest");
+
+ assertEquals("prod/ofbiz/mysql_db_password", aliases.get("jdbc-password.mysql-ofbiz"));
+ assertEquals("prod/ofbiz/anet_txn_key", aliases.get("payment.authorizedotnet.transactionKey"));
+ }
+
+ @Test
+ public void loadIgnoresNonAliasEntries() {
+ Map aliases = SecretProviderUtil.loadKeyAliases("secrethelperstest");
+
+ assertTrue(aliases.keySet().stream().noneMatch(k -> k.contains("not.a.key.alias.entry")));
+ assertEquals(2, aliases.size());
+ }
+
+ @Test
+ public void loadReturnsEmptyMapForResourceWithNoAliasEntries() {
+ // "general" exists on the classpath (framework/common/config/general.properties)
+ // but has no key.alias.* entries.
+ Map aliases = SecretProviderUtil.loadKeyAliases("general");
+
+ assertTrue(aliases.isEmpty());
+ }
+
+ @Test
+ public void loadReturnsEmptyMapForMissingResource() {
+ Map aliases = SecretProviderUtil.loadKeyAliases("no-such-resource-xyz");
+
+ assertTrue(aliases.isEmpty());
+ }
+}
diff --git a/framework/base/src/test/resources/secrethelperstest.properties b/framework/base/src/test/resources/secrethelperstest.properties
new file mode 100644
index 00000000000..ebc6fab33b3
--- /dev/null
+++ b/framework/base/src/test/resources/secrethelperstest.properties
@@ -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.
+###############################################################################
+
+# Fixture for SecretKeyAliasResolverTest
+key.alias.jdbc-password.mysql-ofbiz=prod/ofbiz/mysql_db_password
+key.alias.payment.authorizedotnet.transactionKey=prod/ofbiz/anet_txn_key
+not.a.key.alias.entry=should-be-ignored
+
+# Fixture for SecretCacheTtlTest
+test.cache.ttl.seconds=7200
+test.cache.ttl.invalid=not-a-number
From c6af8db7a7c5d02e1df47dc0fc8f9d91e3d8c16e Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Sat, 27 Jun 2026 09:31:05 +0530
Subject: [PATCH 22/30] Add SecretAuditLog entity and async audit trail for
secret access events
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduce an immutable SecretAuditLog entity so that every secret resolution attempt — fetch, cache hit, fallback, store, rotation poll, and admin action — is recorded in a tamper-evident database row without adding latency to the hot path of OFBiz startup or request processing.
A non-blocking SecretAuditSink interface decouples the resolution hot path from persistence; a bounded async queue drains events on a background thread so FETCH and CACHE_HIT calls never wait on a database write. Each audit row is written in its own independent transaction so it survives a caller rollback. The logger never throws — audit failure must not disrupt the application.
The entity captures who, when, what key, which provider, and outcome. No secret value or vault path is ever stored. An Audit Log Viewer screen and a scheduled purge job are included.
---
.../ofbiz/base/secret/SecretAuditSink.java | 69 ++++
.../base/secret/SecretProviderFactory.java | 11 +-
.../base/secret/SecretValueResolver.java | 30 ++
framework/security/config/security.properties | 12 +
.../webtools/config/WebtoolsUiLabels.xml | 123 +++++++
.../SecretManagerScheduledServiceData.xml | 19 ++
.../data/WebtoolsSecurityGroupDemoData.xml | 6 +
.../WebtoolsSecurityPermissionSeedData.xml | 2 +
framework/webtools/ofbiz-component.xml | 3 +
framework/webtools/servicedef/services.xml | 20 ++
.../webtools/secret/SecretAuditLog.groovy | 123 +++++++
.../webtools/secret/SecretAuditContainer.java | 72 ++++
.../webtools/secret/SecretAuditEvent.java | 100 ++++++
.../webtools/secret/SecretAuditLogger.java | 200 +++++++++++
.../webtools/secret/SecretAuditQueue.java | 164 +++++++++
.../webtools/secret/SecretManagerEvents.java | 169 ++++++++-
.../secret/SecretManagerServices.java | 321 ++++++++++++++++--
.../webtools/template/secret/EncryptValue.ftl | 27 +-
.../template/secret/SecretAuditLog.ftl | 239 +++++++++++++
.../webapp/webtools/WEB-INF/controller.xml | 17 +
.../webtools/widget/SecretManagerScreens.xml | 35 ++
21 files changed, 1710 insertions(+), 52 deletions(-)
create mode 100644 framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretAuditSink.java
create mode 100644 framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/SecretAuditLog.groovy
create mode 100644 framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditContainer.java
create mode 100644 framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditEvent.java
create mode 100644 framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditLogger.java
create mode 100644 framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditQueue.java
create mode 100644 framework/webtools/template/secret/SecretAuditLog.ftl
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretAuditSink.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretAuditSink.java
new file mode 100644
index 00000000000..cffa4ad0bfd
--- /dev/null
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretAuditSink.java
@@ -0,0 +1,69 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.base.secret;
+
+/**
+ * Receives secret access events from {@link SecretValueResolver} and
+ * {@link SecretProviderFactory} for asynchronous persistence.
+ *
+ *
Implementations MUST be non-blocking. These callbacks fire
+ * on the hot path of secret resolution (JDBC password lookups, property reads).
+ * Any synchronous I/O here causes measurable throughput regression. Use a bounded
+ * in-memory queue and drain it on a background thread.
+ *
+ *
Registered once at application startup via
+ * {@link SecretValueResolver#setAuditSink(SecretAuditSink)}. Until a sink is
+ * registered, all events are silently ignored.
+ *
+ *
Events are disabled by default via {@code security.properties} to avoid
+ * audit overhead before an operator explicitly opts in:
+ * {@code secret.audit.log.cache.hits=false},
+ * {@code secret.audit.log.fetch.events=false}.
+ */
+public interface SecretAuditSink {
+
+ /**
+ * Called on every CACHE_HIT in {@link SecretValueResolver#resolveKey(String)}.
+ * May fire thousands of times per second — implementations must use a non-blocking
+ * queue offer and silently drop on overflow.
+ *
+ * @param key the logical secret key, e.g. {@code jdbc-password.default}
+ */
+ void onCacheHit(String key);
+
+ /**
+ * Called when a secret is fetched from the active provider (cache miss).
+ *
+ * @param key the logical secret key
+ * @param accessMode {@code PROVIDER_CALL} or {@code FALLBACK_FILE}
+ * @param outcome {@code SUCCESS}, {@code FAILURE}, or {@code NOT_FOUND}
+ * @param errorCategory null unless outcome is {@code FAILURE};
+ * one of {@code AUTH_FAILED}, {@code NETWORK_TIMEOUT},
+ * {@code PROVIDER_ERROR}, {@code INVALID_CONFIG}
+ */
+ void onFetch(String key, String accessMode, String outcome, String errorCategory);
+
+ /**
+ * Called on each scheduled rotation-poll cache invalidation.
+ *
+ * @param outcome {@code SUCCESS} or {@code FAILURE}
+ * @param errorCategory null unless outcome is {@code FAILURE}
+ */
+ void onRotationPoll(String outcome, String errorCategory);
+}
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
index b4c415ee9ad..1dde85ea996 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java
@@ -83,9 +83,18 @@ public final class SecretProviderFactory {
try {
instance.invalidateCache();
SecretValueResolver.invalidateAll();
- Debug.logInfo("[SECRET_AUDIT] user=system action=scheduledRotationPoll", MODULE);
+ Debug.logInfo("[SECRET_AUDIT] action=ROTATION_POLL user=system provider=" + providerName
+ + " outcome=SUCCESS", MODULE);
+ if (SecretValueResolver.LOG_FETCH_EVENTS) {
+ SecretAuditSink s = SecretValueResolver.getAuditSink();
+ if (s != null) s.onRotationPoll("SUCCESS", null);
+ }
} catch (Exception e) {
Debug.logWarning("SecretProvider: scheduled rotation poll failed: " + e.getMessage(), MODULE);
+ if (SecretValueResolver.LOG_FETCH_EVENTS) {
+ SecretAuditSink s = SecretValueResolver.getAuditSink();
+ if (s != null) s.onRotationPoll("FAILURE", "PROVIDER_ERROR");
+ }
}
}, ROTATION_POLL_SECONDS, ROTATION_POLL_SECONDS, TimeUnit.SECONDS);
Debug.logInfo("SecretProvider: scheduled rotation poll enabled every "
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java
index f07d8537438..cdb28e00152 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java
@@ -96,6 +96,18 @@ private static long parseTtlMillis() {
return Math.min(Math.max(seconds, 1L), MAX_TTL_SECONDS) * 1000L;
}
+ // Phase 2 audit flags — read once at class load; both off by default (opt-in only).
+ // Enabling these adds a non-blocking queue offer on every cache hit / provider fetch.
+ public static final boolean LOG_CACHE_HITS = SECURITY_PROPERTIES != null
+ && "true".equalsIgnoreCase(
+ SECURITY_PROPERTIES.getProperty("secret.audit.log.cache.hits", "false").trim());
+ public static final boolean LOG_FETCH_EVENTS = SECURITY_PROPERTIES != null
+ && "true".equalsIgnoreCase(
+ SECURITY_PROPERTIES.getProperty("secret.audit.log.fetch.events", "false").trim());
+
+ /** Set once at startup by webtools SecretAuditContainer; null until then — all audit calls no-op. */
+ private static volatile SecretAuditSink auditSink;
+
private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>();
// Per-key locks used for stampede protection: only one thread fetches from the provider
@@ -127,6 +139,20 @@ private static long parseTtlMillis() {
private SecretValueResolver() { }
+ /**
+ * Registers the audit sink that receives CACHE_HIT, FETCH, and ROTATION_POLL events.
+ * Called once at OFBiz startup from {@code SecretAuditContainer} in webtools after the
+ * entity layer is available. Pass {@code null} to deregister (e.g. during shutdown).
+ */
+ public static void setAuditSink(SecretAuditSink sink) {
+ auditSink = sink;
+ }
+
+ /** Returns the currently registered audit sink, or {@code null} if none is set. */
+ public static SecretAuditSink getAuditSink() {
+ return auditSink;
+ }
+
/**
* If {@code rawValue} matches {@code LOOKUP(key)}, resolves {@code key} via
* {@link SecretProviderFactory#getInstance()}, caching the result for
@@ -166,6 +192,7 @@ public static String resolveKey(String key) {
if (cached != null && cached.expiry > System.currentTimeMillis()) {
TOTAL_HITS.incrementAndGet();
KEY_HIT_COUNTS.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
+ if (LOG_CACHE_HITS) { SecretAuditSink s = auditSink; if (s != null) s.onCacheHit(key); }
return cached.value;
}
// Per-key lock: only the first thread that finds a stale/absent entry fetches from the
@@ -177,6 +204,7 @@ public static String resolveKey(String key) {
if (rechecked != null && rechecked.expiry > System.currentTimeMillis()) {
TOTAL_HITS.incrementAndGet();
KEY_HIT_COUNTS.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
+ if (LOG_CACHE_HITS) { SecretAuditSink s = auditSink; if (s != null) s.onCacheHit(key); }
return rechecked.value;
}
try {
@@ -184,6 +212,7 @@ public static String resolveKey(String key) {
CACHE.put(key, new CacheEntry(secret, System.currentTimeMillis() + CACHE_TTL_MILLIS));
TOTAL_MISSES.incrementAndGet();
KEY_MISS_COUNTS.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
+ if (LOG_FETCH_EVENTS) { SecretAuditSink s = auditSink; if (s != null) s.onFetch(key, "PROVIDER_CALL", "SUCCESS", null); }
return secret;
} catch (GeneralException e) {
// Log only the exception type — never e.getMessage() — to prevent a provider that
@@ -192,6 +221,7 @@ public static String resolveKey(String key) {
+ "' (" + e.getClass().getSimpleName() + ")", MODULE);
TOTAL_MISSES.incrementAndGet();
KEY_MISS_COUNTS.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
+ if (LOG_FETCH_EVENTS) { SecretAuditSink s = auditSink; if (s != null) s.onFetch(key, "PROVIDER_CALL", "FAILURE", "PROVIDER_ERROR"); }
return "";
}
}
diff --git a/framework/security/config/security.properties b/framework/security/config/security.properties
index 4fd54a1df48..3231aa6de57 100644
--- a/framework/security/config/security.properties
+++ b/framework/security/config/security.properties
@@ -459,3 +459,15 @@ secret.master.key.env.var=OFBIZ_MASTER_KEY
# -- re-encrypt every secret after changing this setting. Default: 310000 (OWASP 2023 guidance).
# -- Legacy installations that encrypted with 10000 iterations must keep this at 10000.
secret.pbkdf2.iterations=310000
+
+# -- Secret Audit Trail: Phase 2 opt-in flags (requires restart to take effect) --
+# -- secret.audit.deployment.mode: DIRECT (OFBiz calls vault directly) or K8S_INJECTED
+# -- (external operator populates env/file; vault-level fetch events are in the operator's log).
+secret.audit.deployment.mode=DIRECT
+# -- Log every CACHE_HIT event (very high volume — off by default).
+# -- Enabling this on a busy system can produce thousands of rows per second.
+secret.audit.log.cache.hits=false
+# -- Log FETCH and ROTATION_POLL events (moderate volume — off by default).
+# -- These are on the hot path of secret resolution; events are queued asynchronously
+# -- in SecretAuditQueue and drained to SecretAuditLog in batches every 2 seconds.
+secret.audit.log.fetch.events=false
diff --git a/framework/webtools/config/WebtoolsUiLabels.xml b/framework/webtools/config/WebtoolsUiLabels.xml
index 8bd1dd9549f..528cbfdd3a3 100644
--- a/framework/webtools/config/WebtoolsUiLabels.xml
+++ b/framework/webtools/config/WebtoolsUiLabels.xml
@@ -2168,6 +2168,129 @@
Total
+
+ Secret Audit Log
+
+
+ You do not have permission to view the Secret Audit Log. The SECRET_AUDIT_VIEW or ENTITY_MAINT permission is required.
+
+
+ User Login ID
+
+
+ Action
+
+
+ Outcome
+
+
+ Secret Key Ref
+
+
+ Date From
+
+
+ Date To
+
+
+ Showing
+
+
+ of
+
+
+ Timestamp
+
+
+ User
+
+
+ Action
+
+
+ Outcome
+
+
+ Error Category
+
+
+ Secret Key Ref
+
+
+ Target
+
+
+ Access Mode
+
+
+ Provider
+
+
+ Deploy Mode
+
+
+ Resource ID
+
+
+ Property ID
+
+
+ No audit records found matching the filter criteria.
+
+
+ Client IP
+
+
+ K8s Injected Mode
+
+
+ OFBiz is running in K8S_INJECTED deployment mode. Vault-level FETCH events are emitted by the external secret operator (CSI driver, ESO, or Vault Agent) and are not recorded here. Only admin actions (STORE, SYNC, FLUSH_CACHE, etc.) appear in this log.
+
+
+ Export to CSV
+
+
+ Download CSV
+
+
+ Date range required. Maximum export window: 90 days. Maximum rows per file: 50,000 (a header comment is added if the cap is hit — narrow the range and re-export).
+
+
+ Retention & Phase 2 Settings
+
+
+ Retention period
+
+
+ days (PCI-DSS 4.0 §10.7 minimum: 365)
+
+
+ Purge batch size
+
+
+ Next scheduled purge
+
+
+ FETCH / ROTATION_POLL logging
+
+
+ CACHE_HIT logging
+
+
+ To change the retention period or batch size, update the secret.audit.retention.days / secret.audit.purge.batch.size SystemProperty rows and restart the purge job. To enable Phase 2 logging, set secret.audit.log.fetch.events=true or secret.audit.log.cache.hits=true in security.properties and restart OFBiz.
+
+
+ Save Retention Settings
+
+
+ Provider
+
+
+ Deploy Mode
+
+
+ View Secret Audit Log
+ Entität XML ToolsEntity XML Tools
diff --git a/framework/webtools/data/SecretManagerScheduledServiceData.xml b/framework/webtools/data/SecretManagerScheduledServiceData.xml
index 6cca1cc28b8..b7f5e9d87ed 100644
--- a/framework/webtools/data/SecretManagerScheduledServiceData.xml
+++ b/framework/webtools/data/SecretManagerScheduledServiceData.xml
@@ -27,4 +27,23 @@ under the License.
+
+
+
+
+
+
+
+
diff --git a/framework/webtools/data/WebtoolsSecurityGroupDemoData.xml b/framework/webtools/data/WebtoolsSecurityGroupDemoData.xml
index 894f5f22390..6ce1d0dcbb1 100644
--- a/framework/webtools/data/WebtoolsSecurityGroupDemoData.xml
+++ b/framework/webtools/data/WebtoolsSecurityGroupDemoData.xml
@@ -58,6 +58,12 @@ under the License.
+
+
+
+
+
+
diff --git a/framework/webtools/data/WebtoolsSecurityPermissionSeedData.xml b/framework/webtools/data/WebtoolsSecurityPermissionSeedData.xml
index a060edca164..d6c1654fa2d 100644
--- a/framework/webtools/data/WebtoolsSecurityPermissionSeedData.xml
+++ b/framework/webtools/data/WebtoolsSecurityPermissionSeedData.xml
@@ -46,6 +46,7 @@ under the License.
+
@@ -74,6 +75,7 @@ under the License.
+
diff --git a/framework/webtools/ofbiz-component.xml b/framework/webtools/ofbiz-component.xml
index 4168962e257..3d06922332f 100644
--- a/framework/webtools/ofbiz-component.xml
+++ b/framework/webtools/ofbiz-component.xml
@@ -24,6 +24,7 @@ under the License.
+
@@ -35,4 +36,6 @@ under the License.
location="webapp/webtools"
base-permission="OFBTOOLS,WEBTOOLS"
mount-point="/webtools"/>
+
diff --git a/framework/webtools/servicedef/services.xml b/framework/webtools/servicedef/services.xml
index 9098fc9adda..4fe2e47fabf 100644
--- a/framework/webtools/servicedef/services.xml
+++ b/framework/webtools/servicedef/services.xml
@@ -198,6 +198,26 @@ under the License.
clean post-rotation metrics without a JVM restart.
+
+ Updates the secret.audit.retention.days and secret.audit.purge.batch.size
+ SystemProperty rows from the Audit Log Viewer retention settings panel.
+ Requires SECRET_MAINT.
+
+
+
+
+
+ Deletes SecretAuditLog rows older than the retention period configured in the
+ secret.audit.retention.days SystemProperty (default 365 days, minimum required by
+ PCI-DSS 4.0 §10.7). Rows are removed in bounded batches of at most
+ secret.audit.purge.batch.size (default 500) per database transaction. Scheduled weekly
+ by the SECRET_AUDIT_PURGE JobSandbox entry seeded in SecretManagerScheduledServiceData.xml.
+
+ Saves service and related artifacts diagram to an Apple EOModelBundle file.
diff --git a/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/SecretAuditLog.groovy b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/SecretAuditLog.groovy
new file mode 100644
index 00000000000..8a5377b4b7b
--- /dev/null
+++ b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/SecretAuditLog.groovy
@@ -0,0 +1,123 @@
+/*
+ * 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.webtools.secret
+
+import org.apache.ofbiz.base.secret.SecretValueResolver
+import org.apache.ofbiz.base.util.UtilProperties
+import org.apache.ofbiz.entity.condition.EntityCondition
+import org.apache.ofbiz.entity.condition.EntityOperator
+import org.apache.ofbiz.entity.util.EntityUtilProperties
+
+import java.net.URLEncoder
+import java.sql.Timestamp
+
+// --- pagination ---
+viewIndex = (parameters.viewIndex ?: 0) as int
+viewSize = (parameters.viewSize ?: 50) as int
+if (viewIndex < 0) viewIndex = 0
+if (viewSize < 1) viewSize = 50
+
+// --- filter parameters ---
+filterUser = parameters.filterUserLoginId ?: ''
+filterAction = parameters.filterAction ?: ''
+filterOutcome = parameters.filterOutcome ?: ''
+filterKey = parameters.filterSecretKeyRef ?: ''
+filterProviderType = parameters.filterProviderType ?: ''
+filterDeployMode = parameters.filterDeploymentMode ?: ''
+filterFrom = parameters.filterDateFrom ?: ''
+filterTo = parameters.filterDateTo ?: ''
+
+// --- build conditions ---
+conditions = []
+if (filterUser) conditions << EntityCondition.makeCondition('userLoginId', EntityOperator.EQUALS, filterUser)
+if (filterAction) conditions << EntityCondition.makeCondition('action', EntityOperator.EQUALS, filterAction)
+if (filterOutcome) conditions << EntityCondition.makeCondition('outcome', EntityOperator.EQUALS, filterOutcome)
+if (filterKey) conditions << EntityCondition.makeCondition('secretKeyRef', EntityOperator.EQUALS, filterKey)
+if (filterProviderType) conditions << EntityCondition.makeCondition('providerType', EntityOperator.EQUALS, filterProviderType)
+if (filterDeployMode) conditions << EntityCondition.makeCondition('deploymentMode', EntityOperator.EQUALS, filterDeployMode)
+if (filterFrom) {
+ try {
+ conditions << EntityCondition.makeCondition('auditTimestamp',
+ EntityOperator.GREATER_THAN_EQUAL_TO, Timestamp.valueOf(filterFrom + ' 00:00:00'))
+ } catch (IllegalArgumentException ignore) {}
+}
+if (filterTo) {
+ try {
+ conditions << EntityCondition.makeCondition('auditTimestamp',
+ EntityOperator.LESS_THAN_EQUAL_TO, Timestamp.valueOf(filterTo + ' 23:59:59'))
+ } catch (IllegalArgumentException ignore) {}
+}
+
+// --- query ---
+where = conditions ? EntityCondition.makeCondition(conditions, EntityOperator.AND) : null
+
+// Returns a fresh, optionally-filtered query each call — avoids passing null to where() in Groovy
+Closure filtered = { where ? from('SecretAuditLog').where(where) : from('SecretAuditLog') }
+
+listSize = filtered().queryCount()
+auditRows = filtered().orderBy('-auditTimestamp').offset(viewIndex * viewSize).limit(viewSize).queryList()
+
+highIndex = Math.min((viewIndex + 1) * viewSize, (int) listSize)
+
+// --- URL-encoded filter params for pagination links ---
+Closure enc = { String v -> URLEncoder.encode(v ?: '', 'UTF-8') }
+filterParams = "filterUserLoginId=${enc(filterUser as String)}" +
+ "&filterAction=${enc(filterAction as String)}" +
+ "&filterOutcome=${enc(filterOutcome as String)}" +
+ "&filterSecretKeyRef=${enc(filterKey as String)}" +
+ "&filterProviderType=${enc(filterProviderType as String)}" +
+ "&filterDeploymentMode=${enc(filterDeployMode as String)}" +
+ "&filterDateFrom=${enc(filterFrom as String)}" +
+ "&filterDateTo=${enc(filterTo as String)}" +
+ "&viewSize=${viewSize}"
+
+// --- populate context ---
+context.auditRows = auditRows
+context.listSize = listSize
+context.viewIndex = viewIndex
+context.viewSize = viewSize
+context.lowIndex = listSize > 0 ? viewIndex * viewSize + 1 : 0
+context.highIndex = highIndex
+context.filterUserLoginId = filterUser
+context.filterAction = filterAction
+context.filterOutcome = filterOutcome
+context.filterSecretKeyRef = filterKey
+context.filterProviderType = filterProviderType
+context.filterDeploymentMode = filterDeployMode
+context.filterDateFrom = filterFrom
+context.filterDateTo = filterTo
+context.filterParams = filterParams
+
+// --- K8s mode notice ---
+deploymentMode = UtilProperties.getPropertyValue('security', 'secret.audit.deployment.mode', 'DIRECT')
+context.deploymentMode = deploymentMode
+context.isK8sMode = 'K8S_INJECTED' == deploymentMode
+
+// --- retention settings ---
+context.retentionDays = EntityUtilProperties.getPropertyValue('security', 'secret.audit.retention.days', delegator) ?: '365'
+context.purgeBatchSize = EntityUtilProperties.getPropertyValue('security', 'secret.audit.purge.batch.size', delegator) ?: '500'
+context.hasSecretMaint = security.hasPermission('SECRET_MAINT', session)
+
+// --- next scheduled purge run ---
+nextJobRun = from('JobSandbox').where('jobId', 'SECRET_AUDIT_PURGE').queryOne()
+context.nextPurgeRun = nextJobRun?.getTimestamp('runTime')
+
+// --- audit phase-2 flags ---
+context.fetchEventsEnabled = SecretValueResolver.LOG_FETCH_EVENTS
+context.cacheHitsEnabled = SecretValueResolver.LOG_CACHE_HITS
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditContainer.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditContainer.java
new file mode 100644
index 00000000000..6d2d82ba816
--- /dev/null
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditContainer.java
@@ -0,0 +1,72 @@
+/*******************************************************************************
+ * 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.webtools.secret;
+
+import java.util.List;
+
+import org.apache.ofbiz.base.container.Container;
+import org.apache.ofbiz.base.container.ContainerException;
+import org.apache.ofbiz.base.secret.SecretValueResolver;
+import org.apache.ofbiz.base.start.StartupCommand;
+import org.apache.ofbiz.base.util.Debug;
+
+/**
+ * OFBiz startup container that registers {@link SecretAuditQueue} as the active
+ * {@link org.apache.ofbiz.base.secret.SecretAuditSink} with {@link SecretValueResolver}.
+ *
+ *
This bridges the module-layer gap: {@code SecretValueResolver} lives in
+ * {@code framework/base} (no entity dependency), while {@link SecretAuditQueue}
+ * lives in {@code framework/webtools} (has {@code DelegatorFactory} access).
+ * The container wires them together after OFBiz's entity layer has started.
+ *
+ *
Declared in {@code framework/webtools/ofbiz-component.xml} with
+ * {@code loaders="main"} so it runs after {@code delegator-container}.
+ */
+public final class SecretAuditContainer implements Container {
+
+ private static final String MODULE = SecretAuditContainer.class.getName();
+
+ private String name;
+
+ @Override
+ public void init(List ofbizCommands, String name, String configFile) {
+ this.name = name;
+ }
+
+ @Override
+ public boolean start() throws ContainerException {
+ SecretValueResolver.setAuditSink(SecretAuditQueue.INSTANCE);
+ Debug.logInfo("SecretAuditContainer: SecretAuditQueue registered as audit sink"
+ + " (FETCH events=" + SecretValueResolver.LOG_FETCH_EVENTS
+ + ", CACHE_HIT events=" + SecretValueResolver.LOG_CACHE_HITS + ")", MODULE);
+ return true;
+ }
+
+ @Override
+ public void stop() throws ContainerException {
+ SecretAuditQueue.INSTANCE.shutdown();
+ SecretValueResolver.setAuditSink(null);
+ Debug.logInfo("SecretAuditContainer: SecretAuditQueue shut down", MODULE);
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+}
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditEvent.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditEvent.java
new file mode 100644
index 00000000000..a04e64392c9
--- /dev/null
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditEvent.java
@@ -0,0 +1,100 @@
+/*******************************************************************************
+ * 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.webtools.secret;
+
+import java.util.Objects;
+
+/**
+ * Immutable value object describing a single secret management audit event.
+ *
+ *
Build via {@link #builder()}. Only {@code action} and {@code outcome} are
+ * required; all other fields are nullable and omitted when not applicable to the
+ * call site. {@code deploymentMode} is intentionally absent — it is set by
+ * {@link SecretAuditLogger} from its own static final so call sites cannot pass
+ * a stale or wrong value.
+ *
+ *
Security invariant: this record never holds a secret value,
+ * vault path, or authentication credential. {@code secretKeyRef} is the logical
+ * key name only (e.g. {@code jdbc-password.default}).
+ */
+public record SecretAuditEvent(
+ /** Null for scheduler/system jobs that have no login session. */
+ String userLoginId,
+ /** Null for non-HTTP call sites (scheduler jobs, runSync calls). */
+ String clientIpAddress,
+ /** Logical key name — e.g. {@code jdbc-password.default}. Never a vault path or ARN. */
+ String secretKeyRef,
+ /** {@code SYSTEM_PROPERTY | PASSWORDS_FILE | UNKNOWN} */
+ String secretTarget,
+ /** Required. One of the action values defined in the audit taxonomy (Section 4 of the design). */
+ String action,
+ /** {@code PROVIDER_CALL | CACHE_HIT | FALLBACK_FILE | ENV_VAR | NOT_APPLICABLE} */
+ String accessMode,
+ /** Active provider name at call time — from {@code SecretProviderFactory.getProviderName()}. */
+ String providerType,
+ /** Required. One of the outcome values defined in the audit taxonomy (Section 4 of the design). */
+ String outcome,
+ /** Null unless {@code outcome=FAILURE}. One of: {@code AUTH_FAILED | NETWORK_TIMEOUT | PROVIDER_ERROR | INVALID_CONFIG} */
+ String errorCategory,
+ /** Nullable — populated for SYNC / STORE / ROTATION_POLL events against a SystemProperty row. */
+ String systemResourceId,
+ /** Nullable — populated for SYNC / STORE / ROTATION_POLL events against a SystemProperty row. */
+ String systemPropertyId
+) {
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String userLoginId;
+ private String clientIpAddress;
+ private String secretKeyRef;
+ private String secretTarget;
+ private String action;
+ private String accessMode = "NOT_APPLICABLE";
+ private String providerType;
+ private String outcome;
+ private String errorCategory;
+ private String systemResourceId;
+ private String systemPropertyId;
+
+ private Builder() { }
+
+ public Builder userLoginId(String v) { userLoginId = v; return this; }
+ public Builder clientIpAddress(String v) { clientIpAddress = v; return this; }
+ public Builder secretKeyRef(String v) { secretKeyRef = v; return this; }
+ public Builder secretTarget(String v) { secretTarget = v; return this; }
+ public Builder action(String v) { action = v; return this; }
+ public Builder accessMode(String v) { accessMode = v; return this; }
+ public Builder providerType(String v) { providerType = v; return this; }
+ public Builder outcome(String v) { outcome = v; return this; }
+ public Builder errorCategory(String v) { errorCategory = v; return this; }
+ public Builder systemResourceId(String v) { systemResourceId = v; return this; }
+ public Builder systemPropertyId(String v) { systemPropertyId = v; return this; }
+
+ public SecretAuditEvent build() {
+ Objects.requireNonNull(action, "SecretAuditEvent.action is required");
+ Objects.requireNonNull(outcome, "SecretAuditEvent.outcome is required");
+ return new SecretAuditEvent(userLoginId, clientIpAddress, secretKeyRef,
+ secretTarget, action, accessMode, providerType,
+ outcome, errorCategory, systemResourceId, systemPropertyId);
+ }
+ }
+}
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditLogger.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditLogger.java
new file mode 100644
index 00000000000..d82617d5af9
--- /dev/null
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditLogger.java
@@ -0,0 +1,200 @@
+/*******************************************************************************
+ * 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.webtools.secret;
+
+import java.util.List;
+
+import javax.transaction.Transaction;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilDateTime;
+import org.apache.ofbiz.base.util.UtilProperties;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericDelegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.transaction.GenericTransactionException;
+import org.apache.ofbiz.entity.transaction.TransactionUtil;
+
+/**
+ * Persists {@link SecretAuditEvent} records to the {@code SecretAuditLog} entity.
+ *
+ *
Design constraints
+ *
+ *
Never throws — audit failure must never block the caller. All
+ * exceptions are caught and logged at WARN level.
+ *
Independent transaction — each write suspends the caller's
+ * transaction so the audit row survives a caller rollback. On non-JTA containers
+ * (plain Tomcat) where {@code suspend()} is not supported, the write degrades
+ * gracefully to the caller's transaction rather than being skipped.
+ *
Test guard — skips writes when the delegator base name is
+ * {@code "test"} to avoid polluting audit rows in integration tests.
+ *
Deployment mode — read once at class load from
+ * {@code security.properties}; never re-read per event.
+ *
+ *
+ *
Phase 2 note
+ *
FETCH and CACHE_HIT events must use an async write queue (not these synchronous
+ * methods) because they are on the hot path of secret resolution. See the design
+ * document for the async queue architecture.
+ */
+public final class SecretAuditLogger {
+
+ private static final String MODULE = SecretAuditLogger.class.getName();
+
+ /** Read once — deployment mode never changes at runtime; no per-event property lookup. */
+ private static final String DEPLOYMENT_MODE =
+ UtilProperties.getPropertyValue("security", "secret.audit.deployment.mode", "DIRECT");
+
+ private SecretAuditLogger() { }
+
+ /**
+ * Persists a single audit event in its own independent transaction.
+ * Use for all Phase 1 admin-action call sites.
+ */
+ public static void log(Delegator delegator, SecretAuditEvent event) {
+ if ("test".equalsIgnoreCase(delegator.getDelegatorBaseName())) {
+ return;
+ }
+ Transaction suspended = null;
+ boolean beganNew = false;
+ boolean suspendSucceeded = false;
+ try {
+ try {
+ suspended = TransactionUtil.suspend();
+ suspendSucceeded = true;
+ } catch (GenericTransactionException suspendEx) {
+ // Non-JTA environment (e.g. plain Tomcat): suspend() is not supported.
+ // Degrade gracefully: write in the caller's transaction rather than skipping.
+ Debug.logWarning("SecretAuditLogger: suspend() unsupported, writing in"
+ + " caller's transaction: " + suspendEx.getMessage(), MODULE);
+ }
+ beganNew = TransactionUtil.begin();
+ writeEntry(delegator, event);
+ TransactionUtil.commit(beganNew);
+ } catch (Exception e) {
+ try {
+ TransactionUtil.rollback(beganNew, "SecretAuditLogger write failed", e);
+ } catch (Exception rollbackEx) {
+ // Nested catch required: rollback() itself can throw, breaking the "never throw" contract.
+ Debug.logWarning("SecretAuditLogger: rollback failed: "
+ + rollbackEx.getMessage(), MODULE);
+ }
+ Debug.logWarning("SecretAuditLogger: could not persist audit event: "
+ + e.getMessage(), MODULE);
+ } finally {
+ if (suspendSucceeded && suspended != null) {
+ try {
+ TransactionUtil.resume(suspended);
+ } catch (Exception e) {
+ Debug.logWarning("SecretAuditLogger: could not resume caller transaction",
+ MODULE);
+ }
+ }
+ }
+ }
+
+ /**
+ * Persists a batch of audit events in a single independent transaction.
+ *
+ *
All events share one suspend/begin/commit/resume cycle regardless of list size,
+ * avoiding N transaction context-switches for N-key bulk operations.
+ *
+ *
All-or-nothing semantics: if any {@code writeEntry()} call fails,
+ * the entire batch rolls back. This is acceptable for Phase 1 admin-action batches
+ * (CSV upload, auto-sync) where the batch is bounded and a retry is possible on the
+ * next scheduled run. It is NOT acceptable for Phase 2 FETCH/CACHE_HIT events —
+ * use the async write queue for those instead.
+ */
+ public static void logBatch(Delegator delegator, List events) {
+ if (events == null || events.isEmpty()) {
+ return;
+ }
+ if ("test".equalsIgnoreCase(delegator.getDelegatorBaseName())) {
+ return;
+ }
+ Transaction suspended = null;
+ boolean beganNew = false;
+ boolean suspendSucceeded = false;
+ try {
+ try {
+ suspended = TransactionUtil.suspend();
+ suspendSucceeded = true;
+ } catch (GenericTransactionException suspendEx) {
+ Debug.logWarning("SecretAuditLogger: suspend() unsupported for batch, writing"
+ + " in caller's transaction: " + suspendEx.getMessage(), MODULE);
+ }
+ beganNew = TransactionUtil.begin();
+ for (SecretAuditEvent event : events) {
+ writeEntry(delegator, event);
+ }
+ TransactionUtil.commit(beganNew);
+ } catch (Exception e) {
+ try {
+ TransactionUtil.rollback(beganNew, "SecretAuditLogger batch write failed", e);
+ } catch (Exception rollbackEx) {
+ Debug.logWarning("SecretAuditLogger: batch rollback failed: "
+ + rollbackEx.getMessage(), MODULE);
+ }
+ Debug.logWarning("SecretAuditLogger: could not persist " + events.size()
+ + " audit event(s): " + e.getMessage(), MODULE);
+ } finally {
+ if (suspendSucceeded && suspended != null) {
+ try {
+ TransactionUtil.resume(suspended);
+ } catch (Exception e) {
+ Debug.logWarning("SecretAuditLogger: could not resume caller transaction"
+ + " after batch", MODULE);
+ }
+ }
+ }
+ }
+
+ /**
+ * Writes one {@code SecretAuditLog} row using the two-step PK pattern that matches
+ * {@code EntityAuditLog} ({@code GenericDelegator.java:2663,2678}):
+ * {@code getNextSeqId} then {@code create} — do not use {@code createSetNextSeqId}.
+ */
+ private static void writeEntry(Delegator delegator, SecretAuditEvent event)
+ throws GenericEntityException {
+ // Prefer explicitly-passed identity; fall back to the ThreadLocal stack
+ // the HTTP layer populates. getCurrentUserIdentifier() is an instance method —
+ // not callable on the Delegator interface, so the cast is required.
+ String resolvedUser = event.userLoginId() != null
+ ? event.userLoginId()
+ : ((GenericDelegator) delegator).getCurrentUserIdentifier();
+
+ GenericValue entry = delegator.makeValue("SecretAuditLog");
+ entry.set("secretAuditLogId", delegator.getNextSeqId("SecretAuditLog"));
+ entry.set("auditTimestamp", UtilDateTime.nowTimestamp());
+ entry.set("userLoginId", resolvedUser);
+ entry.set("clientIpAddress", event.clientIpAddress());
+ entry.set("secretKeyRef", event.secretKeyRef());
+ entry.set("secretTarget", event.secretTarget());
+ entry.set("action", event.action());
+ entry.set("accessMode", event.accessMode());
+ entry.set("providerType", event.providerType());
+ entry.set("deploymentMode", DEPLOYMENT_MODE);
+ entry.set("outcome", event.outcome());
+ entry.set("errorCategory", event.errorCategory());
+ entry.set("systemResourceId", event.systemResourceId());
+ entry.set("systemPropertyId", event.systemPropertyId());
+ delegator.create(entry);
+ }
+}
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditQueue.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditQueue.java
new file mode 100644
index 00000000000..5d920ee1434
--- /dev/null
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditQueue.java
@@ -0,0 +1,164 @@
+/*******************************************************************************
+ * 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.webtools.secret;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.ofbiz.base.secret.SecretAuditSink;
+import org.apache.ofbiz.base.secret.SecretProviderFactory;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.DelegatorFactory;
+
+/**
+ * Asynchronous audit event queue for Phase 2 FETCH, CACHE_HIT, and ROTATION_POLL events.
+ *
+ *
Events arrive via the {@link SecretAuditSink} callbacks, which fire on the hot
+ * secret-resolution path. They are enqueued with a non-blocking
+ * {@link ArrayBlockingQueue#offer} and drained in small batches by a single background
+ * writer thread every {@value #DRAIN_INTERVAL_SECONDS} seconds via
+ * {@link SecretAuditLogger#logBatch}.
+ *
+ *
On queue overflow, the event is dropped and a counter is incremented. This is
+ * acceptable for high-frequency FETCH/CACHE_HIT rows — a steady overflow means the
+ * operator should raise {@code secret.audit.queue.capacity} or disable one of the
+ * high-volume flags. It is NOT acceptable for Phase 1 admin events, which use the
+ * synchronous {@link SecretAuditLogger#log} path.
+ *
+ *
Events are disabled by default via {@code security.properties}:
+ * {@code secret.audit.log.fetch.events=false},
+ * {@code secret.audit.log.cache.hits=false}. Enabling them requires a restart.
+ *
+ *
Registered with {@link org.apache.ofbiz.base.secret.SecretValueResolver} by
+ * {@link SecretAuditContainer} at OFBiz startup after the entity layer is available.
+ */
+public final class SecretAuditQueue implements SecretAuditSink {
+
+ private static final String MODULE = SecretAuditQueue.class.getName();
+
+ private static final int QUEUE_CAPACITY = 2000;
+ private static final int DRAIN_BATCH_SIZE = 50;
+ private static final long DRAIN_INTERVAL_SECONDS = 2L;
+ private static final long DROP_LOG_INTERVAL = 100L;
+
+ /** Singleton — registered with SecretValueResolver by SecretAuditContainer at startup. */
+ public static final SecretAuditQueue INSTANCE = new SecretAuditQueue();
+
+ private final ArrayBlockingQueue queue = new ArrayBlockingQueue<>(QUEUE_CAPACITY);
+ private final AtomicLong droppedTotal = new AtomicLong();
+ private final ScheduledExecutorService writer;
+
+ /** Delegator name used when obtaining a Delegator from DelegatorFactory during drain. */
+ private volatile String delegatorName = "default";
+
+ private SecretAuditQueue() {
+ writer = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "SecretAuditQueue-Writer");
+ t.setDaemon(true);
+ return t;
+ });
+ writer.scheduleWithFixedDelay(this::drainQueue,
+ DRAIN_INTERVAL_SECONDS, DRAIN_INTERVAL_SECONDS, TimeUnit.SECONDS);
+ }
+
+ /** Called by {@link SecretAuditContainer} to override the default delegator name. */
+ void setDelegatorName(String name) {
+ this.delegatorName = name;
+ }
+
+ /**
+ * Graceful shutdown: stops the background writer and performs a final best-effort
+ * drain. Called by {@link SecretAuditContainer#stop()}.
+ */
+ public void shutdown() {
+ writer.shutdown();
+ drainQueue();
+ }
+
+ @Override
+ public void onCacheHit(String key) {
+ offer(SecretAuditEvent.builder()
+ .action("CACHE_HIT")
+ .secretKeyRef(key)
+ .accessMode("CACHE_HIT")
+ .providerType(SecretProviderFactory.getProviderName())
+ .outcome("SUCCESS")
+ .build());
+ }
+
+ @Override
+ public void onFetch(String key, String accessMode, String outcome, String errorCategory) {
+ SecretAuditEvent.Builder b = SecretAuditEvent.builder()
+ .action("FETCH")
+ .secretKeyRef(key)
+ .accessMode(accessMode)
+ .providerType(SecretProviderFactory.getProviderName())
+ .outcome(outcome);
+ if (errorCategory != null) {
+ b.errorCategory(errorCategory);
+ }
+ offer(b.build());
+ }
+
+ @Override
+ public void onRotationPoll(String outcome, String errorCategory) {
+ SecretAuditEvent.Builder b = SecretAuditEvent.builder()
+ .action("ROTATION_POLL")
+ .providerType(SecretProviderFactory.getProviderName())
+ .outcome(outcome);
+ if (errorCategory != null) {
+ b.errorCategory(errorCategory);
+ }
+ offer(b.build());
+ }
+
+ private void offer(SecretAuditEvent event) {
+ if (!queue.offer(event)) {
+ long dropped = droppedTotal.incrementAndGet();
+ if (dropped % DROP_LOG_INTERVAL == 1) {
+ Debug.logWarning("SecretAuditQueue: queue full — " + dropped
+ + " event(s) dropped since JVM start (capacity=" + QUEUE_CAPACITY + ")."
+ + " Consider raising secret.audit.queue.capacity or disabling"
+ + " secret.audit.log.cache.hits/fetch.events.", MODULE);
+ }
+ }
+ }
+
+ private void drainQueue() {
+ if (queue.isEmpty()) {
+ return;
+ }
+ Delegator delegator = DelegatorFactory.getDelegator(delegatorName);
+ if (delegator == null) {
+ // Entity layer not ready yet — events remain in the queue for the next cycle.
+ return;
+ }
+ List batch = new ArrayList<>(DRAIN_BATCH_SIZE);
+ queue.drainTo(batch, DRAIN_BATCH_SIZE);
+ if (!batch.isEmpty()) {
+ SecretAuditLogger.logBatch(delegator, batch);
+ }
+ }
+}
diff --git a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
index 999c5cac296..d68fd023d8d 100644
--- a/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
+++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java
@@ -21,8 +21,11 @@
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
+import java.io.PrintWriter;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
+import java.sql.Timestamp;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -41,7 +44,11 @@
import org.apache.ofbiz.base.util.UtilGenerics;
import org.apache.ofbiz.base.util.UtilValidate;
import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.condition.EntityCondition;
+import org.apache.ofbiz.entity.condition.EntityOperator;
+import org.apache.ofbiz.entity.util.EntityQuery;
import org.apache.ofbiz.security.SecuredUpload;
import org.apache.ofbiz.security.Security;
@@ -75,6 +82,12 @@ public static String uploadEncryptedSecrets(HttpServletRequest request, HttpServ
if (security == null
|| (!security.hasPermission("SECRET_MAINT", userLogin)
&& !security.hasPermission("ENTITY_MAINT", userLogin))) {
+ SecretAuditLogger.log(delegator, SecretAuditEvent.builder()
+ .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null)
+ .clientIpAddress(request.getRemoteAddr())
+ .action("STORE")
+ .outcome("DENIED")
+ .build());
request.setAttribute("_ERROR_MESSAGE_", "You do not have permission to perform this operation");
return "error";
}
@@ -115,17 +128,29 @@ public static String uploadEncryptedSecrets(HttpServletRequest request, HttpServ
CSVParser parser = format.parse(reader)) {
for (CSVRecord record : parser) {
long rowNum = record.getRecordNumber() + 1;
+ String rowTarget = getColumn(record, "target");
+ String rowResourceId = getColumn(record, "systemResourceId");
+ String rowPropertyId = getColumn(record, "systemPropertyId");
+ String rowLookupKey = getColumn(record, "lookupKey");
try {
- SecretManagerServices.storeEncryptedSecret(delegator,
- userLogin,
- getColumn(record, "target"),
- getColumn(record, "systemResourceId"),
- getColumn(record, "systemPropertyId"),
- getColumn(record, "lookupKey"),
+ SecretManagerServices.storeEncryptedSecret(delegator, userLogin,
+ rowTarget, rowResourceId, rowPropertyId, rowLookupKey,
getColumn(record, "secretValue"));
successCount++;
} catch (Exception e) {
errors.add("Row " + rowNum + ": " + e.getMessage());
+ // PCI-DSS 10.2: failed stores are individually attributable events too.
+ SecretAuditLogger.log(delegator, SecretAuditEvent.builder()
+ .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null)
+ .clientIpAddress(request.getRemoteAddr())
+ .action("STORE")
+ .secretKeyRef(rowLookupKey)
+ .secretTarget(rowTarget)
+ .systemResourceId(rowResourceId)
+ .systemPropertyId(rowPropertyId)
+ .outcome("FAILURE")
+ .errorCategory("PROVIDER_ERROR")
+ .build());
}
}
} catch (IOException e) {
@@ -251,6 +276,138 @@ private static String getColumn(CSVRecord record, String name) {
return UtilValidate.isEmpty(value) ? null : value;
}
+ /**
+ * Streams a CSV export of SecretAuditLog rows for the requested date range.
+ *
+ *
Rules enforced:
+ *
+ *
Date range (dateFrom, dateTo) is required.
+ *
Window may not exceed 90 days (to prevent runaway memory / bandwidth usage).
+ *
Output is capped at {@value #MAX_EXPORT_ROWS} rows; a comment header is prepended if the
+ * cap is hit, instructing the operator to narrow the range.
A structured audit log entry is written on every successful call so that the
- * who/what/when of each secret operation is preserved for compliance review.
+ *
A {@code SecretAuditLog} row is written on every successful call via
+ * {@link SecretAuditLogger} so the who/what/when of each store is preserved for compliance.
The {@code SECRET_AUDIT_PURGE} JobSandbox entry runs this weekly. PCI-DSS 4.0 §10.7 requires
+ * at least 12 months — do not set {@code secret.audit.retention.days} below 365 in production.