diff --git a/build.gradle b/build.gradle index dde39621c9f..3272e1c530a 100644 --- a/build.gradle +++ b/build.gradle @@ -182,6 +182,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() @@ -750,6 +757,134 @@ 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=]. ' + + '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.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 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 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=. ' + + '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.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') + 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 reEncryptAllSecrets(group: sysadminGroup, + description: 'Re-encrypts every ENC(...) blob in passwords.properties and SystemProperty DB rows ' + + 'with a new master AES key. All decryptions are validated before any writes occur. ' + + 'Usage: ./gradlew reEncryptAllSecrets -PoldMasterKey= -PnewMasterKey= [-PdryRun=true]. ' + + 'Keys may also be supplied via the OFBIZ_OLD_MASTER_KEY / OFBIZ_NEW_MASTER_KEY env vars ' + + 'or entered interactively (run with --no-daemon --console=plain).') { + dependsOn classes + doLast { + def oldKey = project.hasProperty('oldMasterKey') ? project.property('oldMasterKey') + : (System.getenv('OFBIZ_OLD_MASTER_KEY') ?: promptSecret('Enter OLD master key: ')) + def newKey = project.hasProperty('newMasterKey') ? project.property('newMasterKey') + : (System.getenv('OFBIZ_NEW_MASTER_KEY') ?: promptSecret('Enter NEW master key: ')) + def dryRun = project.hasProperty('dryRun') ? project.property('dryRun') : 'false' + javaexec { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'org.apache.ofbiz.base.crypto.ReEncryptSecretsMain' + args oldKey, newKey, dryRun + } + } +} + 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..e970103b3a1 --- /dev/null +++ b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtil.java @@ -0,0 +1,206 @@ +/******************************************************************************* + * 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.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; + +/** + * 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_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 + * {@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 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 KEY_LENGTH_BITS = 256; + + // 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(); + + // 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); + try { + return factory.generateSecret(spec).getEncoded(); + } 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 { + 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); + 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} + * 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); + 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); + } + } + + /** + * 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_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_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(MASTER_KEY_ENV_VAR); + if (masterKey == null || masterKey.isEmpty()) { + throw new GeneralException("Secret '" + secretName + "' is encrypted (ENC(...)) but the " + + MASTER_KEY_ENV_VAR + " 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_MASTER_KEY matches the key used to encrypt", e); + } + } + + /** + * Decrypts {@code encValue} (which must be an {@code ENC(...)} wrapper) with + * {@code oldMasterKey} and immediately re-encrypts the plaintext with {@code newMasterKey}. + * + *

Used by the {@code reEncryptAllSecrets} Gradle task to rotate the master AES key + * without ever exposing the raw plaintext beyond the JVM heap.

+ * + * @param encValue the current {@code ENC(...)} value from {@code passwords.properties} + * or a {@code SystemProperty} row + * @param oldMasterKey the master key currently in use + * @param newMasterKey the new master key to encrypt with + * @return a fresh {@code ENC(...)} value encrypted under {@code newMasterKey} + * @throws GeneralException if {@code encValue} is not a valid {@code ENC(...)} wrapper, + * if decryption fails (wrong key or corrupted data), + * or if re-encryption fails + */ + public static String reEncrypt(String encValue, String oldMasterKey, String newMasterKey) + throws GeneralException { + if (encValue == null || !encValue.startsWith("ENC(") || !encValue.endsWith(")")) { + throw new GeneralException("Value is not a valid ENC(...) wrapper: " + encValue); + } + String base64 = encValue.substring("ENC(".length(), encValue.length() - 1); + String plainText = decrypt(base64, oldMasterKey); + return "ENC(" + encrypt(plainText, newMasterKey) + ")"; + } + + private ConfigCryptoUtil() { } + + /** + * Command-line helper that prints {@code ENC()} for a given master key and plaintext + * 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) { + 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/crypto/ReEncryptSecretsMain.java b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ReEncryptSecretsMain.java new file mode 100644 index 00000000000..c21e7a8bd13 --- /dev/null +++ b/framework/base/src/main/java/org/apache/ofbiz/base/crypto/ReEncryptSecretsMain.java @@ -0,0 +1,397 @@ +/******************************************************************************* + * 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.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; + +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +/** + * Standalone entry point for the {@code reEncryptAllSecrets} Gradle task. + * + *

Re-encrypts every {@code ENC(...)} blob in {@code passwords.properties} and in every + * {@code SystemProperty} DB row using a new master key. All decryptions are validated before + * any writes occur — if a single decryption fails the task aborts with no side effects.

+ * + *

Write order: DB transaction first (fully rollback-able), then atomic file rename. If the + * file rename fails after DB commit the new values are logged at ERROR level for manual recovery.

+ * + *

Invoked by the Gradle task; do not call directly.

+ */ +public final class ReEncryptSecretsMain { + + private static final String MODULE = ReEncryptSecretsMain.class.getName(); + private static final String PASSWORDS_FILE = "framework/base/config/passwords.properties"; + private static final String ENTITY_ENGINE_FILE = "framework/entity/config/entityengine.xml"; + private static final String ENC_PREFIX = "ENC("; + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + Debug.logError("Usage: ReEncryptSecretsMain [true|false (dryRun)]", MODULE); + System.exit(1); + } + String oldKey = args[0]; + String newKey = args[1]; + boolean dryRun = args.length >= 3 && "true".equalsIgnoreCase(args[2]); + + Debug.logWarning("[reEncryptAllSecrets] Master keys passed as command-line arguments are visible in" + + " shell history and 'ps' output. Prefer the OFBIZ_OLD_MASTER_KEY / OFBIZ_NEW_MASTER_KEY env vars.", MODULE); + Debug.logInfo("[reEncryptAllSecrets] dry-run=" + dryRun, MODULE); + + // ── Phase 1: passwords.properties ──────────────────────────────────────────────────── + Path pwdPath = Paths.get(PASSWORDS_FILE); + if (!Files.exists(pwdPath)) { + Debug.logError("[reEncryptAllSecrets] passwords.properties not found at " + pwdPath.toAbsolutePath(), MODULE); + System.exit(1); + } + List originalLines = Files.readAllLines(pwdPath, StandardCharsets.UTF_8); + // key → new ENC(...) value for lines that need updating + Map fileUpdates = new LinkedHashMap<>(); + + Debug.logInfo("[reEncryptAllSecrets] Scanning passwords.properties ...", MODULE); + for (String line : originalLines) { + String trimmed = line.trim(); + if (trimmed.startsWith("#") || !trimmed.contains("=")) { + continue; + } + int eq = trimmed.indexOf('='); + String propKey = trimmed.substring(0, eq).trim(); + String propVal = trimmed.substring(eq + 1).trim(); + if (!propVal.startsWith(ENC_PREFIX)) { + continue; + } + try { + String newEnc = ConfigCryptoUtil.reEncrypt(propVal, oldKey, newKey); + fileUpdates.put(propKey, newEnc); + Debug.logInfo("[reEncryptAllSecrets] file: " + propKey + " -> re-encrypted OK", MODULE); + } catch (GeneralException e) { + Debug.logError("[reEncryptAllSecrets] Failed to re-encrypt file key '" + propKey + + "': " + e.getMessage() + " — aborting, no changes written.", MODULE); + System.exit(1); + } + } + Debug.logInfo("[reEncryptAllSecrets] passwords.properties: " + fileUpdates.size() + " value(s) to re-encrypt.", MODULE); + + // ── Phase 2: SystemProperty DB rows ────────────────────────────────────────────────── + DatasourceInfo ds = parseDatasource(); + List dbRows = new ArrayList<>(); + + if (ds != null) { + String jdbcPassword = resolveJdbcPassword(ds, originalLines, oldKey); + if (jdbcPassword == null) { + Debug.logWarning("[reEncryptAllSecrets] Could not resolve JDBC password; skipping DB re-encryption.", MODULE); + } else { + Debug.logInfo("[reEncryptAllSecrets] Connecting to datasource '" + ds.getName() + "' at " + ds.getJdbcUri() + " ...", MODULE); + try (Connection conn = DriverManager.getConnection(ds.getJdbcUri(), ds.getJdbcUsername(), jdbcPassword)) { + dbRows = loadAndReEncryptDbRows(conn, oldKey, newKey); + } catch (SQLException e) { + Debug.logError("[reEncryptAllSecrets] DB connection or query failed: " + e.getMessage() + + " — aborting, no changes written.", MODULE); + System.exit(1); + } + } + } else { + Debug.logWarning("[reEncryptAllSecrets] Could not parse entityengine.xml; skipping DB re-encryption.", MODULE); + } + Debug.logInfo("[reEncryptAllSecrets] SystemProperty: " + dbRows.size() + " row(s) to re-encrypt.", MODULE); + + // ── Dry-run: print plan and exit ────────────────────────────────────────────────────── + if (dryRun) { + Debug.logInfo("[reEncryptAllSecrets] DRY-RUN — no changes will be written.", MODULE); + if (!fileUpdates.isEmpty()) { + Debug.logInfo("[reEncryptAllSecrets] passwords.properties changes:", MODULE); + fileUpdates.forEach((k, v) -> Debug.logInfo(" " + k + " = " + v, MODULE)); + } + if (!dbRows.isEmpty()) { + Debug.logInfo("[reEncryptAllSecrets] SystemProperty DB changes:", MODULE); + for (SystemPropertyRow row : dbRows) { + Debug.logInfo(" [" + row.getResourceId() + "/" + row.getPropertyId() + "] = " + row.getNewValue(), MODULE); + } + } + Debug.logInfo("[reEncryptAllSecrets] Dry-run complete. Re-run without -PdryRun=true to apply.", MODULE); + return; + } + + // ── Phase 3: Write DB (transactional) ──────────────────────────────────────────────── + if (!dbRows.isEmpty() && ds != null) { + String jdbcPassword = resolveJdbcPassword(ds, originalLines, oldKey); + Debug.logInfo("[reEncryptAllSecrets] Writing " + dbRows.size() + " SystemProperty row(s) in a single transaction ...", MODULE); + try (Connection conn = DriverManager.getConnection(ds.getJdbcUri(), ds.getJdbcUsername(), jdbcPassword)) { + conn.setAutoCommit(false); + try (PreparedStatement ps = conn.prepareStatement( + "UPDATE SYSTEM_PROPERTY SET SYSTEM_PROPERTY_VALUE = ? " + + "WHERE SYSTEM_RESOURCE_ID = ? AND SYSTEM_PROPERTY_ID = ?")) { + for (SystemPropertyRow row : dbRows) { + ps.setString(1, row.getNewValue()); + ps.setString(2, row.getResourceId()); + ps.setString(3, row.getPropertyId()); + ps.addBatch(); + } + ps.executeBatch(); + } + conn.commit(); + Debug.logInfo("[reEncryptAllSecrets] DB transaction committed.", MODULE); + } catch (SQLException e) { + Debug.logError("[reEncryptAllSecrets] DB write failed (transaction rolled back): " + e.getMessage(), MODULE); + System.exit(1); + } + } + + // ── Phase 4: Write passwords.properties (atomic rename) ─────────────────────────────── + if (!fileUpdates.isEmpty()) { + Debug.logInfo("[reEncryptAllSecrets] Writing passwords.properties ...", MODULE); + List newLines = new ArrayList<>(originalLines.size()); + for (String line : originalLines) { + String trimmed = line.trim(); + if (!trimmed.startsWith("#") && trimmed.contains("=")) { + int eq = trimmed.indexOf('='); + String propKey = trimmed.substring(0, eq).trim(); + if (fileUpdates.containsKey(propKey)) { + newLines.add(propKey + "=" + fileUpdates.get(propKey)); + continue; + } + } + newLines.add(line); + } + String newContent = String.join(System.lineSeparator(), newLines) + System.lineSeparator(); + Path temp = null; + try { + temp = Files.createTempFile(pwdPath.getParent(), "passwords-", ".tmp"); + Files.write(temp, newContent.getBytes(StandardCharsets.UTF_8)); + try { + Files.move(temp, pwdPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temp, pwdPath, StandardCopyOption.REPLACE_EXISTING); + } + Debug.logInfo("[reEncryptAllSecrets] passwords.properties updated.", MODULE); + } catch (IOException e) { + Debug.logError("[reEncryptAllSecrets] Failed to write passwords.properties: " + e.getMessage(), MODULE); + if (!dbRows.isEmpty()) { + Debug.logError("[reEncryptAllSecrets] DB is already committed. Manual recovery — new file values:", MODULE); + fileUpdates.forEach((k, v) -> Debug.logError(" " + k + "=" + v, MODULE)); + } + if (temp != null) { + try { + Files.deleteIfExists(temp); + } catch (IOException ignored) { + // best-effort cleanup — ignore + } + } + System.exit(1); + } + } + + Debug.logInfo("[reEncryptAllSecrets] Done. Update OFBIZ_MASTER_KEY to the new key before restarting OFBiz.", MODULE); + } + + // ── Helpers ────────────────────────────────────────────────────────────────────────────── + + private static List loadAndReEncryptDbRows(Connection conn, String oldKey, String newKey) + throws SQLException { + List rows = new ArrayList<>(); + String sql = "SELECT SYSTEM_RESOURCE_ID, SYSTEM_PROPERTY_ID, SYSTEM_PROPERTY_VALUE" + + " FROM SYSTEM_PROPERTY WHERE SYSTEM_PROPERTY_VALUE LIKE 'ENC(%'"; + try (PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String resourceId = rs.getString("SYSTEM_RESOURCE_ID"); + String propertyId = rs.getString("SYSTEM_PROPERTY_ID"); + String currentValue = rs.getString("SYSTEM_PROPERTY_VALUE"); + try { + String newValue = ConfigCryptoUtil.reEncrypt(currentValue, oldKey, newKey); + rows.add(new SystemPropertyRow(resourceId, propertyId, newValue)); + Debug.logInfo("[reEncryptAllSecrets] db: [" + resourceId + "/" + propertyId + "] re-encrypted OK", MODULE); + } catch (GeneralException e) { + throw new SQLException("Failed to re-encrypt SystemProperty [" + resourceId + "/" + propertyId + + "]: " + e.getMessage() + " — aborting, no changes written.", e); + } + } + } + return rows; + } + + private static String resolveJdbcPassword(DatasourceInfo ds, List pwdLines, String oldKey) { + if (ds.getJdbcPassword() != null) { + return ds.getJdbcPassword(); + } + if (ds.getJdbcPasswordLookup() == null) { + return null; + } + String lookupKey = "jdbc-password." + ds.getJdbcPasswordLookup(); + for (String line : pwdLines) { + String trimmed = line.trim(); + if (trimmed.startsWith(lookupKey + "=")) { + String val = trimmed.substring(lookupKey.length() + 1).trim(); + if (val.startsWith(ENC_PREFIX)) { + try { + String base64 = val.substring(ENC_PREFIX.length(), val.length() - 1); + return ConfigCryptoUtil.decrypt(base64, oldKey); + } catch (GeneralException e) { + Debug.logWarning("[reEncryptAllSecrets] Could not decrypt JDBC password for '" + + lookupKey + "': " + e.getMessage(), MODULE); + return null; + } + } + return val; + } + } + return null; + } + + private static DatasourceInfo parseDatasource() { + File entityEngineFile = new File(ENTITY_ENGINE_FILE); + if (!entityEngineFile.exists()) { + return null; + } + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc; + try (InputStream is = Files.newInputStream(entityEngineFile.toPath())) { + doc = builder.parse(is); + } + doc.getDocumentElement().normalize(); + + // Find the default delegator's datasource name for org.apache.ofbiz group + String datasourceName = null; + NodeList delegators = doc.getElementsByTagName("delegator"); + for (int i = 0; i < delegators.getLength(); i++) { + Element del = (Element) delegators.item(i); + if ("default".equals(del.getAttribute("name"))) { + NodeList groupMaps = del.getElementsByTagName("group-map"); + for (int j = 0; j < groupMaps.getLength(); j++) { + Element gm = (Element) groupMaps.item(j); + if ("org.apache.ofbiz".equals(gm.getAttribute("group-name"))) { + datasourceName = gm.getAttribute("datasource-name"); + break; + } + } + break; + } + } + if (datasourceName == null) { + return null; + } + + // Find the datasource element + NodeList datasources = doc.getElementsByTagName("datasource"); + for (int i = 0; i < datasources.getLength(); i++) { + Element ds = (Element) datasources.item(i); + if (datasourceName.equals(ds.getAttribute("name"))) { + NodeList inlineJdbcs = ds.getElementsByTagName("inline-jdbc"); + if (inlineJdbcs.getLength() > 0) { + Element jdbc = (Element) inlineJdbcs.item(0); + String uri = jdbc.getAttribute("jdbc-uri"); + String user = jdbc.getAttribute("jdbc-username"); + String pwd = jdbc.getAttribute("jdbc-password"); + String pwdLookup = jdbc.getAttribute("jdbc-password-lookup"); + return new DatasourceInfo(datasourceName, uri, user, + pwd.isEmpty() ? null : pwd, + pwdLookup.isEmpty() ? null : pwdLookup); + } + } + } + } catch (Exception e) { + Debug.logWarning("[reEncryptAllSecrets] Could not parse entityengine.xml: " + e.getMessage(), MODULE); + } + return null; + } + + private static final class DatasourceInfo { + private final String name; + private final String jdbcUri; + private final String jdbcUsername; + private final String jdbcPassword; + private final String jdbcPasswordLookup; + + DatasourceInfo(String name, String jdbcUri, String jdbcUsername, + String jdbcPassword, String jdbcPasswordLookup) { + this.name = name; + this.jdbcUri = jdbcUri; + this.jdbcUsername = jdbcUsername; + this.jdbcPassword = jdbcPassword; + this.jdbcPasswordLookup = jdbcPasswordLookup; + } + + String getName() { + return name; + } + String getJdbcUri() { + return jdbcUri; + } + String getJdbcUsername() { + return jdbcUsername; + } + String getJdbcPassword() { + return jdbcPassword; + } + String getJdbcPasswordLookup() { + return jdbcPasswordLookup; + } + } + + private static final class SystemPropertyRow { + private final String resourceId; + private final String propertyId; + private final String newValue; + + SystemPropertyRow(String resourceId, String propertyId, String newValue) { + this.resourceId = resourceId; + this.propertyId = propertyId; + this.newValue = newValue; + } + + String getResourceId() { + return resourceId; + } + String getPropertyId() { + return propertyId; + } + String getNewValue() { + return newValue; + } + } + + private ReEncryptSecretsMain() { + } +} 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..cf3f879e7c9 --- /dev/null +++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FallbackSecretProvider.java @@ -0,0 +1,149 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.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 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}. + * 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.

+ */ +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 after all retries and allows fallback + */ + public FallbackSecretProvider(SecretProvider primary, SecretProvider fallback) { + this.primary = primary; + this.fallback = fallback; + } + + @Override + public String getSecret(String key) throws GeneralException { + 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 + } + } + } + + // 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. + */ + 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/FileBasedSecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.java new file mode 100644 index 00000000000..6bcffc8cb07 --- /dev/null +++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/FileBasedSecretProvider.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.crypto.ConfigCryptoUtil; +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.

+ * + *

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_MASTER_KEY} environment variable.

+ */ +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 ConfigCryptoUtil.decryptIfEncrypted(value, key); + } +} 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/SecretProvider.java b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java new file mode 100644 index 00000000000..2f04ba69ed5 --- /dev/null +++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProvider.java @@ -0,0 +1,113 @@ +/******************************************************************************* + * 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:

+ *
+ *   org.example.ofbiz.vault.AwsSecretsManagerProvider
+ * 
+ * + *

Optional client-side encryption ({@code ENC(...)})

+ *

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_MASTER_KEY} environment variable at runtime and is never stored + * in config files or in the remote vault.

+ */ +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; + * for a missing key the message should include "not found", "NotFound", + * or "does not exist" + */ + 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; + } + + /** + * 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 new file mode 100644 index 00000000000..1dde85ea996 --- /dev/null +++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretProviderFactory.java @@ -0,0 +1,198 @@ +/******************************************************************************* + * 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.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. + * + *

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}.

+ * + *

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}.

+ */ +@ThreadSafe +public final class SecretProviderFactory { + + private static final String MODULE = SecretProviderFactory.class.getName(); + + private static final Properties SECURITY_PROPERTIES = UtilProperties.getProperties("security"); + + // 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] 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 " + + 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)"; + } + instance = provider; + providerName = name; + } + + /** Returns the active {@link SecretProvider} instance. */ + public static SecretProvider getInstance() { + 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/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/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..8c40a13a714 --- /dev/null +++ b/framework/base/src/main/java/org/apache/ofbiz/base/secret/SecretValueResolver.java @@ -0,0 +1,375 @@ +/******************************************************************************* + * 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.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; + +/** + * Resolves configuration values of the form {@code LOOKUP(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 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 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} + * (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(); + + // 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 SECURITY_PROPERTIES = UtilProperties.getProperties("security"); + + 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) + "\\((.+)\\)$"); + + // 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; + } + + // 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 + // 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<>(); + // Last-access timestamp (millis since epoch) per key — updated on every hit or miss. + private static final ConcurrentHashMap KEY_LAST_ACCESS_TIMES = 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() { } + + /** + * 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 + * {@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 LOOKUP(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; + } + 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()) { + TOTAL_HITS.incrementAndGet(); + KEY_HIT_COUNTS.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet(); + KEY_LAST_ACCESS_TIMES.computeIfAbsent(key, k -> new AtomicLong()).set(System.currentTimeMillis()); + 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 + // 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(); + KEY_LAST_ACCESS_TIMES.computeIfAbsent(key, k -> new AtomicLong()).set(System.currentTimeMillis()); + if (LOG_CACHE_HITS) { + SecretAuditSink s = auditSink; + if (s != null) { + s.onCacheHit(key); + } + } + 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(); + KEY_LAST_ACCESS_TIMES.computeIfAbsent(key, k -> new AtomicLong()).set(System.currentTimeMillis()); + 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 + // 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(); + KEY_LAST_ACCESS_TIMES.computeIfAbsent(key, k -> new AtomicLong()).set(System.currentTimeMillis()); + if (LOG_FETCH_EVENTS) { + SecretAuditSink s = auditSink; + if (s != null) { + s.onFetch(key, "PROVIDER_CALL", "FAILURE", "PROVIDER_ERROR"); + } + } + 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(); + KEY_LAST_ACCESS_TIMES.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, "lastAccessedMs": t}}

+ */ + 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); + stats.put("lastAccessedMs", getLastAccess(key)); + 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 long getLastAccess(String key) { + AtomicLong ts = KEY_LAST_ACCESS_TIMES.get(key); + return ts == null ? 0L : ts.get(); + } + + 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/main/java/org/apache/ofbiz/base/util/UtilProperties.java b/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilProperties.java index 5b3239e31e2..1adf38018cf 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/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..cecd24fd3d3 --- /dev/null +++ b/framework/base/src/test/java/org/apache/ofbiz/base/crypto/ConfigCryptoUtilTest.java @@ -0,0 +1,166 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.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")); + } + + // ── reEncrypt tests ────────────────────────────────────────────────────────────────────── + + @Test + public void reEncryptRoundTrips() throws GeneralException { + String oldKey = "old-master-key"; + String newKey = "new-master-key"; + String plain = "secret-db-password"; + String encWithOldKey = "ENC(" + ConfigCryptoUtil.encrypt(plain, oldKey) + ")"; + String encWithNewKey = ConfigCryptoUtil.reEncrypt(encWithOldKey, oldKey, newKey); + assertTrue("reEncrypt output must be ENC(...) wrapped", encWithNewKey.startsWith("ENC(") && encWithNewKey.endsWith(")")); + String base64 = encWithNewKey.substring("ENC(".length(), encWithNewKey.length() - 1); + assertEquals("plaintext must survive a full reEncrypt cycle", plain, ConfigCryptoUtil.decrypt(base64, newKey)); + } + + @Test + public void reEncryptWithSameKeyPreservesPlaintext() throws GeneralException { + String key = "same-key-both-ends"; + String plain = "unchanged-value"; + String enc = "ENC(" + ConfigCryptoUtil.encrypt(plain, key) + ")"; + String reEnc = ConfigCryptoUtil.reEncrypt(enc, key, key); + String base64 = reEnc.substring("ENC(".length(), reEnc.length() - 1); + assertEquals("plaintext must be identical when old and new key are the same", plain, ConfigCryptoUtil.decrypt(base64, key)); + } + + @Test + public void reEncryptProducesEncWrapper() throws GeneralException { + String oldKey = "key-a"; + String newKey = "key-b"; + String enc = "ENC(" + ConfigCryptoUtil.encrypt("value", oldKey) + ")"; + String result = ConfigCryptoUtil.reEncrypt(enc, oldKey, newKey); + assertTrue("output must start with ENC(", result.startsWith("ENC(")); + assertTrue("output must end with )", result.endsWith(")")); + } + + @Test(expected = GeneralException.class) + public void reEncryptWithWrongOldKeyThrows() throws GeneralException { + String enc = "ENC(" + ConfigCryptoUtil.encrypt("secret", "correct-key") + ")"; + ConfigCryptoUtil.reEncrypt(enc, "wrong-key", "new-key"); + } + + @Test(expected = GeneralException.class) + public void reEncryptRejectsNonEncValue() throws GeneralException { + ConfigCryptoUtil.reEncrypt("plaintext-no-wrapper", "oldKey", "newKey"); + } + + @Test(expected = GeneralException.class) + public void reEncryptRejectsMissingCloseParen() throws GeneralException { + ConfigCryptoUtil.reEncrypt("ENC(base64withnoclose", "oldKey", "newKey"); + } + + @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..8ef2fdfc02e --- /dev/null +++ b/framework/base/src/test/java/org/apache/ofbiz/base/secret/FallbackSecretProviderTest.java @@ -0,0 +1,163 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.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..0b68400e018 --- /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. + * + *

The factory resolves a {@link SecretProvider} via {@link java.util.ServiceLoader}. In the + * Gradle multi-module build all plugin JARs are on the test classpath, so the resolved provider + * may be any registered implementation (e.g. AwsSecretsManagerProvider). Tests here only assert + * behaviour that holds regardless of which provider is active.

+ */ +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 providerNameEndsWithProvider() { + // All SecretProvider implementations follow the convention of ending with "Provider". + String name = SecretProviderFactory.getProviderName(); + assertTrue("Expected provider name to end with 'Provider', got: " + name, + name.endsWith("Provider")); + } +} 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/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..14ff3c2277b --- /dev/null +++ b/framework/base/src/test/java/org/apache/ofbiz/base/secret/SecretValueResolverTest.java @@ -0,0 +1,178 @@ +/******************************************************************************* + * 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.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} + * (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 { + + private static final String M = SecretValueResolver.MARKER_NAME; + + @Before + public void clearCache() { + SecretValueResolver.invalidateAll(); + } + + @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(M + "(jdbc-password.h2-ofbiz)")); + // Cached lookup should return the same value. + assertEquals("ofbiz", SecretValueResolver.resolve(M + "(jdbc-password.h2-ofbiz)")); + } + + @Test + public void unresolvableKeyReturnsEmptyString() { + 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/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 diff --git a/framework/common/entitydef/entitymodel.xml b/framework/common/entitydef/entitymodel.xml index 47385cc61a8..9dfb3e1abaf 100644 --- a/framework/common/entitydef/entitymodel.xml +++ b/framework/common/entitydef/entitymodel.xml @@ -883,12 +883,14 @@ under the License. - + + + 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); 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..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 @@ -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,47 @@ private static Map getSystemPropertyValue(String resource, Strin return results; } + /** + * Resolves the effective value of a {@code SystemProperty} record: + *
    + *
  • 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(...)}
  • + *
+ */ + 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 key '" + lookup + "'", MODULE); + String resolved = SecretValueResolver.resolveKey(lookup); + if (UtilValidate.isNotEmpty(resolved)) { + return resolved; + } + Debug.logWarning("EntityUtilProperties: systemPropertyLookup key '" + 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); } diff --git a/framework/security/config/security.properties b/framework/security/config/security.properties index 088a4399b4c..3231aa6de57 100644 --- a/framework/security/config/security.properties +++ b/framework/security/config/security.properties @@ -421,3 +421,53 @@ 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. +# -- 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 +# -- 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 +# -- 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 + +# -- 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 5f257dcdf47..9556ca46114 100644 --- a/framework/webtools/config/WebtoolsUiLabels.xml +++ b/framework/webtools/config/WebtoolsUiLabels.xml @@ -2022,6 +2022,302 @@ 实体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>=LOOKUP(<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, the plain key is stored in systemPropertyLookup + 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 + + + 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 + + + Last Accessed + + + Secret Usage Statistics + + + View full stats + + + Reset all counters to zero. Useful to observe clean post-rotation metrics without a JVM restart. + + + No secret lookups recorded since the last JVM start or stats reset. + + + Reachable + + + Unreachable (check server logs) + + + 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 Tools Entity XML Tools @@ -4176,6 +4472,9 @@ 性能测试 性能測試 + + You do not have permission to manage secrets. The SECRET_MAINT or ENTITY_MAINT permission is required. + WebTools Autorisierungsfehler Web Tools Permission Error diff --git a/framework/webtools/data/SecretManagerScheduledServiceData.xml b/framework/webtools/data/SecretManagerScheduledServiceData.xml new file mode 100644 index 00000000000..b7f5e9d87ed --- /dev/null +++ b/framework/webtools/data/SecretManagerScheduledServiceData.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + 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 196acc8d0f4..d6c1654fa2d 100644 --- a/framework/webtools/data/WebtoolsSecurityPermissionSeedData.xml +++ b/framework/webtools/data/WebtoolsSecurityPermissionSeedData.xml @@ -44,6 +44,10 @@ under the License. + + + + @@ -70,6 +74,8 @@ under the License. + + diff --git a/framework/webtools/entitydef/entitymodel-secret.xml b/framework/webtools/entitydef/entitymodel-secret.xml new file mode 100644 index 00000000000..73bb4984f59 --- /dev/null +++ b/framework/webtools/entitydef/entitymodel-secret.xml @@ -0,0 +1,108 @@ + + + + + Secret Management Entities + Entities for the OFBiz Secret Management audit trail. + 1.0 + + + + + + + + + + + + + + + + + + + + + Logical key name (e.g. jdbc-password.default). Never a vault path, ARN, or secret value. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/framework/webtools/ofbiz-component.xml b/framework/webtools/ofbiz-component.xml index 9a073e129ae..ff8a6eb1dfb 100644 --- a/framework/webtools/ofbiz-component.xml +++ b/framework/webtools/ofbiz-component.xml @@ -24,7 +24,9 @@ under the License. + + + + + diff --git a/framework/webtools/servicedef/services.xml b/framework/webtools/servicedef/services.xml index d7d3fa80d97..65ebf94c30a 100644 --- a/framework/webtools/servicedef/services.xml +++ b/framework/webtools/servicedef/services.xml @@ -125,6 +125,93 @@ 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. + + + + + + + + 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). + + + + + + 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. + + + 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/EncryptValue.groovy b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy new file mode 100644 index 00000000000..dac7ab1f590 --- /dev/null +++ b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/EncryptValue.groovy @@ -0,0 +1,39 @@ +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() +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')] +] + +// Provider health probe: call the active provider directly (bypasses SecretValueResolver so +// no cache touch and no audit event is written). A "not found" exception means the vault is +// reachable — the ping key simply doesn't exist, which is expected. +try { + SecretProviderFactory.getInstance().getSecret('__ping__') + context.providerHealthy = true +} catch (Exception e) { + String msg = e.getMessage() ?: '' + context.providerHealthy = (msg.contains('not found') || msg.contains('NotFound') + || msg.contains('does not exist') || msg.contains('ResourceNotFoundException') + || msg.contains('SecretNotFoundException')) +} + +// Auto-load usage stats on every page load so the summary is always visible without +// requiring the operator to click Refresh first. +context.usageSummary = SecretValueResolver.getUsageSummary() +context.usageReportRows = SecretValueResolver.getUsageReport().collect { key, stats -> + [key, stats.hits ?: 0L, stats.misses ?: 0L, stats.total ?: 0L] +} 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..0f23a12344e --- /dev/null +++ b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/SecretAuditLog.groovy @@ -0,0 +1,140 @@ +/* + * 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.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.putAll([ + auditRows: auditRows, + listSize: listSize, + viewIndex: viewIndex, + viewSize: viewSize, + lowIndex: listSize > 0 ? viewIndex * viewSize + 1 : 0, + highIndex: highIndex, + filterUserLoginId: filterUser, + filterAction: filterAction, + filterOutcome: filterOutcome, + filterSecretKeyRef: filterKey, + filterProviderType: filterProviderType, + filterDeploymentMode: filterDeployMode, + filterDateFrom: filterFrom, + filterDateTo: filterTo, + filterParams: filterParams +]) + +// --- K8s mode notice --- +deploymentMode = UtilProperties.getPropertyValue('security', 'secret.audit.deployment.mode', 'DIRECT') +context.deploymentMode = deploymentMode +context.isK8sMode = deploymentMode == 'K8S_INJECTED' + +// --- 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/groovy/org/apache/ofbiz/webtools/secret/SecretUsageStats.groovy b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/SecretUsageStats.groovy new file mode 100644 index 00000000000..d5efd240a60 --- /dev/null +++ b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/secret/SecretUsageStats.groovy @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import org.apache.ofbiz.base.secret.SecretValueResolver +import java.text.SimpleDateFormat + +context.usageSummary = SecretValueResolver.getUsageSummary() + +SimpleDateFormat fmt = new SimpleDateFormat('yyyy-MM-dd HH:mm:ss', Locale.US) +context.usageReportRows = SecretValueResolver.getUsageReport().collect { key, stats -> + long lastMs = (stats.lastAccessedMs ?: 0L) as long + String lastStr = lastMs > 0L ? fmt.format(new Date(lastMs)) : '—' + [key, stats.hits ?: 0L, stats.misses ?: 0L, stats.total ?: 0L, lastStr] +} 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 be22773f6c0..c07e7e07f1c 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 @@ -970,6 +970,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/SecretAuditContainer.java b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditContainer.java new file mode 100644 index 00000000000..6f893d11ab0 --- /dev/null +++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditContainer.java @@ -0,0 +1,88 @@ +/******************************************************************************* + * 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.ContainerConfig; +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; + private String delegatorName = "default"; + + @Override + public void init(List ofbizCommands, String name, String configFile) { + this.name = name; + try { + ContainerConfig.Configuration cfg = ContainerConfig.getConfiguration(name); + String configured = ContainerConfig.getPropertyValue(cfg, "delegator-name", "default"); + if (configured != null && !configured.isEmpty()) { + this.delegatorName = configured; + } + } catch (ContainerException e) { + // No container configuration registered for this name (common in unit tests and + // in deployments where the property block is omitted). Use the "default" fallback. + Debug.logInfo("SecretAuditContainer: no container configuration found for '" + + name + "'; using delegator 'default'", MODULE); + } + } + + @Override + public boolean start() throws ContainerException { + SecretAuditQueue.INSTANCE.setDelegatorName(delegatorName); + SecretValueResolver.setAuditSink(SecretAuditQueue.INSTANCE); + Debug.logInfo("SecretAuditContainer: SecretAuditQueue registered as audit sink" + + " using delegator '" + delegatorName + "'" + + " (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..a6d7bd26f31 --- /dev/null +++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditEvent.java @@ -0,0 +1,132 @@ +/******************************************************************************* + * 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..4eac4da5a8e --- /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..784fc6135c3 --- /dev/null +++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretAuditQueue.java @@ -0,0 +1,174 @@ +/******************************************************************************* + * 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; + } + + /** Package-private — for unit tests only. Returns the current number of queued events. */ + int queueSize() { + return queue.size(); + } + + /** Package-private — for unit tests only. Discards all queued events without persisting them. */ + void clearForTesting() { + queue.clear(); + } + + /** + * 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 new file mode 100644 index 00000000000..e790c0de329 --- /dev/null +++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerEvents.java @@ -0,0 +1,448 @@ +/******************************************************************************* + * 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.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; +import java.util.Set; + +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.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; + +/** + * 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); + + 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("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"; + } + + 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; + 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, + 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) { + 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.
  • + *
+ */ + 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"); + String secretValue = getColumn(record, "secretValue"); + + // 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); + // 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; + } + + /** 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 (!SecretManagerServices.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; + } + + /** + * 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.
  • + *
+ * Optional filters (filterUserLoginId, filterAction, filterOutcome, filterSecretKeyRef) are + * carried from the viewer page and applied to the export query. + */ + private static final int MAX_EXPORT_ROWS = 50000; + private static final int MAX_EXPORT_WINDOW_DAYS = 90; + + public static String exportSecretAuditLogs(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.hasPermission("SECRET_AUDIT_VIEW", userLogin) + && !security.hasPermission("ENTITY_MAINT", userLogin)) { + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null) + .clientIpAddress(request.getRemoteAddr()) + .action("EXPORT_AUDIT") + .outcome("DENIED") + .build()); + request.setAttribute("_ERROR_MESSAGE_", "You do not have permission to export audit logs."); + return "error"; + } + + String dateFrom = UtilValidate.isEmpty(request.getParameter("filterDateFrom")) ? null + : request.getParameter("filterDateFrom").trim(); + String dateTo = UtilValidate.isEmpty(request.getParameter("filterDateTo")) ? null + : request.getParameter("filterDateTo").trim(); + + if (dateFrom == null || dateTo == null) { + request.setAttribute("_ERROR_MESSAGE_", "Date range (From and To) is required for export."); + return "error"; + } + + Timestamp tsFrom; + Timestamp tsTo; + try { + tsFrom = Timestamp.valueOf(dateFrom + " 00:00:00"); + tsTo = Timestamp.valueOf(dateTo + " 23:59:59"); + } catch (IllegalArgumentException e) { + request.setAttribute("_ERROR_MESSAGE_", "Invalid date format — use YYYY-MM-DD."); + return "error"; + } + if (tsTo.before(tsFrom)) { + request.setAttribute("_ERROR_MESSAGE_", "'Date To' must be on or after 'Date From'."); + return "error"; + } + long windowDays = Duration.between(tsFrom.toInstant(), tsTo.toInstant()).toDays(); + if (windowDays > MAX_EXPORT_WINDOW_DAYS) { + request.setAttribute("_ERROR_MESSAGE_", "Export window may not exceed " + MAX_EXPORT_WINDOW_DAYS + + " days. Requested: " + windowDays + " days."); + return "error"; + } + + List conds = new ArrayList<>(); + conds.add(EntityCondition.makeCondition("auditTimestamp", EntityOperator.GREATER_THAN_EQUAL_TO, tsFrom)); + conds.add(EntityCondition.makeCondition("auditTimestamp", EntityOperator.LESS_THAN_EQUAL_TO, tsTo)); + String filterUser = request.getParameter("filterUserLoginId"); + String filterAction = request.getParameter("filterAction"); + String filterOutcome = request.getParameter("filterOutcome"); + String filterKey = request.getParameter("filterSecretKeyRef"); + String filterProviderType = request.getParameter("filterProviderType"); + String filterDeployMode = request.getParameter("filterDeploymentMode"); + if (UtilValidate.isNotEmpty(filterUser)) { + conds.add(EntityCondition.makeCondition("userLoginId", EntityOperator.EQUALS, filterUser)); + } + if (UtilValidate.isNotEmpty(filterAction)) { + conds.add(EntityCondition.makeCondition("action", EntityOperator.EQUALS, filterAction)); + } + if (UtilValidate.isNotEmpty(filterOutcome)) { + conds.add(EntityCondition.makeCondition("outcome", EntityOperator.EQUALS, filterOutcome)); + } + if (UtilValidate.isNotEmpty(filterKey)) { + conds.add(EntityCondition.makeCondition("secretKeyRef", EntityOperator.EQUALS, filterKey)); + } + if (UtilValidate.isNotEmpty(filterProviderType)) { + conds.add(EntityCondition.makeCondition("providerType", EntityOperator.EQUALS, filterProviderType)); + } + if (UtilValidate.isNotEmpty(filterDeployMode)) { + conds.add(EntityCondition.makeCondition("deploymentMode", EntityOperator.EQUALS, filterDeployMode)); + } + EntityCondition where = EntityCondition.makeCondition(conds, EntityOperator.AND); + + List rows; + try { + // Fetch one extra row to detect whether the cap was hit without a COUNT query. + rows = EntityQuery.use(delegator).from("SecretAuditLog") + .where(where).orderBy("auditTimestamp") + .maxRows(MAX_EXPORT_ROWS + 1).queryList(); + } catch (GenericEntityException e) { + Debug.logError(e, "exportSecretAuditLogs: query failed", MODULE); + request.setAttribute("_ERROR_MESSAGE_", "Export query failed."); + return "error"; + } + boolean capped = rows.size() > MAX_EXPORT_ROWS; + if (capped) rows = rows.subList(0, MAX_EXPORT_ROWS); + + String filename = "secret-audit-" + dateFrom + "-to-" + dateTo + ".csv"; + response.setContentType("text/csv;charset=UTF-8"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + try { + PrintWriter writer = response.getWriter(); + if (capped) { + writer.println("# NOTICE: export capped at " + MAX_EXPORT_ROWS + + " rows — narrow the date range to retrieve remaining records."); + } + writer.println("secretAuditLogId,auditTimestamp,userLoginId,clientIpAddress," + + "action,outcome,errorCategory,secretKeyRef,secretTarget,accessMode," + + "providerType,deploymentMode,systemResourceId,systemPropertyId"); + for (GenericValue row : rows) { + writer.println( + csvEscape(row.getString("secretAuditLogId")) + "," + + csvEscape(String.valueOf(row.get("auditTimestamp"))) + "," + + csvEscape(row.getString("userLoginId")) + "," + + csvEscape(row.getString("clientIpAddress")) + "," + + csvEscape(row.getString("action")) + "," + + csvEscape(row.getString("outcome")) + "," + + csvEscape(row.getString("errorCategory")) + "," + + csvEscape(row.getString("secretKeyRef")) + "," + + csvEscape(row.getString("secretTarget")) + "," + + csvEscape(row.getString("accessMode")) + "," + + csvEscape(row.getString("providerType")) + "," + + csvEscape(row.getString("deploymentMode")) + "," + + csvEscape(row.getString("systemResourceId")) + "," + + csvEscape(row.getString("systemPropertyId"))); + } + writer.flush(); + } catch (IOException e) { + Debug.logError(e, "exportSecretAuditLogs: write failed", MODULE); + return "error"; + } + return "success"; + } + + private static String csvEscape(String val) { + if (val == null) return ""; + if (val.contains(",") || val.contains("\"") || val.contains("\n") || val.contains("\r")) { + return "\"" + val.replace("\"", "\"\"") + "\""; + } + return val; + } + + /** + * 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..5003ffe123f --- /dev/null +++ b/framework/webtools/src/main/java/org/apache/ofbiz/webtools/secret/SecretManagerServices.java @@ -0,0 +1,997 @@ +/******************************************************************************* + * 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.nio.file.StandardCopyOption; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; +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.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.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.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.entity.util.EntityUtilProperties; +import org.apache.ofbiz.security.Security; +import org.apache.ofbiz.service.DispatchContext; +import org.apache.ofbiz.service.LocalDispatcher; +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."; + /** 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. */ + static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[\\w.\\-]+$"); + + /** Read once at class load — deployment mode never changes at runtime. */ + private static final String DEPLOYMENT_MODE = + UtilProperties.getPropertyValue("security", "secret.audit.deployment.mode", "DIRECT"); + + private SecretManagerServices() { } + + private static boolean hasSecretMaintPermission(Security security, GenericValue userLogin) { + return security != null + && (security.hasPermission("SECRET_MAINT", userLogin) + || security.hasPermission("ENTITY_MAINT", userLogin)); + } + + private static Map deniedResult(Delegator delegator, GenericValue userLogin, + String action, String secretKeyRef, String secretTarget, + String systemResourceId, String systemPropertyId) { + String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown"; + String userLoginIdOrNull = (userLogin != null) ? userLogin.getString("userLoginId") : null; + Debug.logWarning("[SECRET_AUDIT] action=" + action + " user=" + userLoginId + + (secretKeyRef != null ? " key=" + secretKeyRef : "") + + (secretTarget != null ? " target=" + secretTarget : "") + + " mode=" + DEPLOYMENT_MODE + " accessMode=NOT_APPLICABLE outcome=DENIED", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLoginIdOrNull) + .action(action) + .secretKeyRef(secretKeyRef) + .secretTarget(secretTarget) + .systemResourceId(systemResourceId) + .systemPropertyId(systemPropertyId) + .outcome("DENIED") + .build()); + return ServiceUtil.returnError("You do not have permission to perform this operation"); + } + + /** + * 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) { + Delegator delegator = dctx.getDelegator(); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + if (!hasSecretMaintPermission(dctx.getSecurity(), userLogin)) { + String testKeyRaw = (String) context.get("testKey"); + return deniedResult(delegator, userLogin, "TEST_CONNECTION", + UtilValidate.isNotEmpty(testKeyRaw) ? testKeyRaw.trim() : null, null, null, null); + } + 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"); + } + String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown"; + String userLoginIdOrNull = (userLogin != null) ? userLogin.getString("userLoginId") : null; + try { + String value = SecretProviderFactory.getInstance().getSecret(testKey); + String providerName = SecretProviderFactory.getProviderName(); + Debug.logInfo("[SECRET_AUDIT] action=TEST_CONNECTION user=" + userLoginId + + " key=" + testKey + + " provider=" + providerName + + " mode=" + DEPLOYMENT_MODE + + " accessMode=PROVIDER_CALL outcome=SUCCESS", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLoginIdOrNull) + .action("TEST_CONNECTION") + .secretKeyRef(testKey) + .providerType(providerName) + .accessMode("PROVIDER_CALL") + .outcome("SUCCESS") + .build()); + 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(); + String providerName = SecretProviderFactory.getProviderName(); + // "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] action=TEST_CONNECTION user=" + userLoginId + + " key=" + testKey + + " provider=" + providerName + + " mode=" + DEPLOYMENT_MODE + + " accessMode=PROVIDER_CALL outcome=NOT_FOUND", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLoginIdOrNull) + .action("TEST_CONNECTION") + .secretKeyRef(testKey) + .providerType(providerName) + .accessMode("PROVIDER_CALL") + .outcome("NOT_FOUND") + .build()); + return ServiceUtil.returnSuccess("Connected — key '" + testKey + "' was not found in the provider (vault is reachable)"); + } + // Full SDK message logged separately for server-side debugging only — never in the [SECRET_AUDIT] line. + Debug.logWarning("[SECRET_AUDIT] action=TEST_CONNECTION user=" + userLoginId + + " key=" + testKey + + " provider=" + providerName + + " mode=" + DEPLOYMENT_MODE + + " accessMode=PROVIDER_CALL outcome=FAILURE errorCategory=PROVIDER_ERROR", MODULE); + Debug.logWarning("Secret provider connection test failed for key '" + testKey + "': " + msg, MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLoginIdOrNull) + .action("TEST_CONNECTION") + .secretKeyRef(testKey) + .providerType(providerName) + .accessMode("PROVIDER_CALL") + .outcome("FAILURE") + .errorCategory("PROVIDER_ERROR") + .build()); + 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) { + Delegator delegator = dctx.getDelegator(); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + if (!hasSecretMaintPermission(dctx.getSecurity(), userLogin)) { + return deniedResult(delegator, userLogin, "RESET_STATS", null, null, null, null); + } + String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown"; + SecretValueResolver.resetUsageStats(); + Debug.logInfo("[SECRET_AUDIT] action=RESET_STATS user=" + userLoginId + + " mode=" + DEPLOYMENT_MODE + " accessMode=NOT_APPLICABLE outcome=SUCCESS", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null) + .action("RESET_STATS") + .outcome("SUCCESS") + .build()); + 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) { + Delegator delegator = dctx.getDelegator(); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + if (!hasSecretMaintPermission(dctx.getSecurity(), userLogin)) { + return deniedResult(delegator, userLogin, "VIEW_STATS", null, null, null, null); + } + 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) { + Delegator delegator = dctx.getDelegator(); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + if (!hasSecretMaintPermission(dctx.getSecurity(), userLogin)) { + return deniedResult(delegator, userLogin, "FLUSH_CACHE", null, null, null, null); + } + String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown"; + SecretValueResolver.invalidateAll(); + UtilCache.clearCachesThatStartWith("properties.UtilProperties"); + SecretProviderFactory.invalidateCache(); + Debug.logInfo("[SECRET_AUDIT] action=FLUSH_CACHE user=" + userLoginId + + " mode=" + DEPLOYMENT_MODE + " accessMode=NOT_APPLICABLE outcome=SUCCESS", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null) + .action("FLUSH_CACHE") + .outcome("SUCCESS") + .build()); + 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) { + Delegator delegator = dctx.getDelegator(); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + if (!hasSecretMaintPermission(dctx.getSecurity(), userLogin)) { + return deniedResult(delegator, userLogin, "RELOAD_PROVIDER", null, null, null, null); + } + String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown"; + UtilCache.clearCachesThatStartWith("properties.UtilProperties"); + SecretProviderFactory.reload(); + String providerName = SecretProviderFactory.getProviderName(); + Debug.logInfo("[SECRET_AUDIT] action=RELOAD_PROVIDER user=" + userLoginId + + " provider=" + providerName + + " mode=" + DEPLOYMENT_MODE + " accessMode=NOT_APPLICABLE outcome=SUCCESS", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null) + .action("RELOAD_PROVIDER") + .providerType(providerName) + .outcome("SUCCESS") + .build()); + 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 secretTarget = (String) context.get("secretTarget"); + String systemResourceId = (String) context.get("systemResourceId"); + String systemPropertyId = (String) context.get("systemPropertyId"); + String lookupKey = (String) context.get("lookupKey"); + if (!hasSecretMaintPermission(dctx.getSecurity(), userLogin)) { + return deniedResult(delegator, userLogin, "SYNC", + UtilValidate.isNotEmpty(lookupKey) ? lookupKey.trim() : null, + secretTarget, systemResourceId, systemPropertyId); + } + String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown"; + + 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] action=SYNC user=" + userLoginId + + " key=" + providerKey + + " target=" + secretTarget + + " provider=" + SecretProviderFactory.getProviderName() + + " mode=" + DEPLOYMENT_MODE + + " accessMode=PROVIDER_CALL outcome=FAILURE errorCategory=PROVIDER_ERROR", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null) + .action("SYNC") + .secretKeyRef(providerKey) + .secretTarget(secretTarget) + .providerType(SecretProviderFactory.getProviderName()) + .accessMode("PROVIDER_CALL") + .systemResourceId(systemResourceId) + .systemPropertyId(systemPropertyId) + .outcome("FAILURE") + .errorCategory("PROVIDER_ERROR") + .build()); + 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 { + String currentValue = readCurrentStoredValue(delegator, secretTarget, systemResourceId, systemPropertyId, providerKey); + if (currentValue != null && currentValue.equals(freshValue)) { + Debug.logInfo("[SECRET_AUDIT] action=SYNC user=" + userLoginId + + " key=" + providerKey + + " target=" + secretTarget + + " provider=" + SecretProviderFactory.getProviderName() + + " mode=" + DEPLOYMENT_MODE + + " accessMode=PROVIDER_CALL outcome=NO_CHANGE", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null) + .action("SYNC") + .secretKeyRef(providerKey) + .secretTarget(secretTarget) + .providerType(SecretProviderFactory.getProviderName()) + .accessMode("PROVIDER_CALL") + .systemResourceId(systemResourceId) + .systemPropertyId(systemPropertyId) + .outcome("NO_CHANGE") + .build()); + 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); + return ServiceUtil.returnError(e.getMessage()); + } + Debug.logInfo("[SECRET_AUDIT] action=SYNC user=" + userLoginId + + " key=" + providerKey + + " target=" + secretTarget + + " provider=" + SecretProviderFactory.getProviderName() + + " mode=" + DEPLOYMENT_MODE + + " accessMode=PROVIDER_CALL outcome=SUCCESS", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null) + .action("SYNC") + .secretKeyRef(providerKey) + .secretTarget(secretTarget) + .providerType(SecretProviderFactory.getProviderName()) + .accessMode("PROVIDER_CALL") + .systemResourceId(systemResourceId) + .systemPropertyId(systemPropertyId) + .outcome("SUCCESS") + .build()); + 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) { + Delegator delegator = dctx.getDelegator(); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + if (!hasSecretMaintPermission(dctx.getSecurity(), userLogin)) { + return deniedResult(delegator, userLogin, "ROTATION_POLL", null, null, null, null); + } + if (!UtilProperties.getPropertyAsBoolean("security", "secret.rotation.autosync.enabled", false)) { + return ServiceUtil.returnSuccess( + "Automatic secret rotation sync is disabled (secret.rotation.autosync.enabled=false)"); + } + 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 auto-sync failed for " + systemResourceId + "." + systemPropertyId + + ": " + ServiceUtil.getErrorMessage(syncResult), MODULE); + } else if (Boolean.TRUE.equals(syncResult.get("changed"))) { + syncedCount++; + } else { + unchangedCount++; + } + } catch (GeneralException e) { + failedCount++; + Debug.logWarning("Secret auto-sync exception for " + systemResourceId + "." + systemPropertyId + + " (" + e.getClass().getSimpleName() + ")", MODULE); + } + } + } catch (GeneralException e) { + Debug.logError(e, MODULE); + Debug.logWarning("[SECRET_AUDIT] action=ROTATION_POLL" + + " mode=" + DEPLOYMENT_MODE + " outcome=FAILURE errorCategory=PROVIDER_ERROR", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null) + .action("ROTATION_POLL") + .providerType(SecretProviderFactory.getProviderName()) + .outcome("FAILURE") + .errorCategory("PROVIDER_ERROR") + .build()); + return ServiceUtil.returnError("Unable to query SystemProperty rows for rotation sync: " + e.getMessage()); + } + + long totalChecked = syncedCount + unchangedCount + failedCount; + String pollOutcome = failedCount > 0 ? "PARTIAL_FAILURE" : "SUCCESS"; + String providerName = SecretProviderFactory.getProviderName(); + Debug.logInfo("[SECRET_AUDIT] action=ROTATION_POLL checked=" + totalChecked + + " synced=" + syncedCount + " unchanged=" + unchangedCount + " failed=" + failedCount + + " provider=" + providerName + " mode=" + DEPLOYMENT_MODE + " outcome=" + pollOutcome, MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null) + .action("ROTATION_POLL") + .providerType(providerName) + .outcome(pollOutcome) + .build()); + 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. */ + 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 (!hasSecretMaintPermission(dctx.getSecurity(), userLogin)) { + return deniedResult(delegator, userLogin, "STORE", + UtilValidate.isNotEmpty(lookupKey) ? lookupKey.trim() : null, + secretTarget, systemResourceId, systemPropertyId); + } + + if (UtilValidate.isNotEmpty(secretValueConfirm) && !secretValueConfirm.equals(secretValue)) { + return ServiceUtil.returnError("Secret Value and Confirm Secret Value do not match"); + } + + try { + storeEncryptedSecret(delegator, userLogin, 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. + * + *

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.

+ */ + 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"); + } + 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. + // 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); + 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. + // 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] action=STORE user=" + userLoginId + + " key=" + lookupKey + + " target=" + secretTarget + + " mode=" + DEPLOYMENT_MODE + " accessMode=NOT_APPLICABLE outcome=SUCCESS", MODULE); + SecretAuditLogger.log(delegator, SecretAuditEvent.builder() + .userLoginId(userLogin != null ? userLogin.getString("userLoginId") : null) + .action("STORE") + .secretKeyRef(lookupKey) + .secretTarget(secretTarget) + .systemResourceId(systemResourceId) + .systemPropertyId(systemPropertyId) + .outcome("SUCCESS") + .build()); + } + + /** + * Updates the {@code secret.audit.retention.days} and {@code secret.audit.purge.batch.size} + * {@code SystemProperty} rows from the Audit Log Viewer retention settings panel. + * Requires {@code SECRET_MAINT} (enforced inline; see {@link #hasSecretMaintPermission}). + */ + public static Map updateSecretAuditRetention(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + GenericValue userLogin = (GenericValue) context.get("userLogin"); + if (!hasSecretMaintPermission(dctx.getSecurity(), userLogin)) { + return deniedResult(delegator, userLogin, "UPDATE_RETENTION", null, null, null, null); + } + String userLoginId = (userLogin != null) ? userLogin.getString("userLoginId") : "unknown"; + + String retentionDaysStr = UtilValidate.isNotEmpty((String) context.get("retentionDays")) + ? ((String) context.get("retentionDays")).trim() : ""; + String batchSizeStr = UtilValidate.isNotEmpty((String) context.get("batchSize")) + ? ((String) context.get("batchSize")).trim() : ""; + + int retentionDays; + try { + retentionDays = Integer.parseInt(retentionDaysStr); + } catch (NumberFormatException e) { + return ServiceUtil.returnError("Retention period must be a positive integer"); + } + if (retentionDays < 1) { + return ServiceUtil.returnError("Retention period must be at least 1 day"); + } + + int batchSize; + try { + batchSize = Integer.parseInt(batchSizeStr); + } catch (NumberFormatException e) { + return ServiceUtil.returnError("Purge batch size must be a positive integer"); + } + if (batchSize < 1 || batchSize > 10000) { + return ServiceUtil.returnError("Purge batch size must be between 1 and 10000"); + } + + try { + upsertSystemProperty(delegator, "security", "secret.audit.retention.days", + String.valueOf(retentionDays)); + upsertSystemProperty(delegator, "security", "secret.audit.purge.batch.size", + String.valueOf(batchSize)); + } catch (GenericEntityException e) { + Debug.logError(e, MODULE); + return ServiceUtil.returnError("Failed to update retention settings: " + e.getMessage()); + } + + Debug.logInfo("[SECRET_AUDIT] action=UPDATE_RETENTION user=" + userLoginId + + " retentionDays=" + retentionDays + " batchSize=" + batchSize + + " mode=" + DEPLOYMENT_MODE + " outcome=SUCCESS", MODULE); + return ServiceUtil.returnSuccess( + "Audit log retention updated: " + retentionDays + " days, batch size " + batchSize); + } + + private static void upsertSystemProperty(Delegator delegator, String resourceId, + String propertyId, String value) throws GenericEntityException { + GenericValue sp = EntityQuery.use(delegator).from("SystemProperty") + .where("systemResourceId", resourceId, "systemPropertyId", propertyId) + .queryOne(); + if (sp == null) { + sp = delegator.makeValue("SystemProperty", + "systemResourceId", resourceId, "systemPropertyId", propertyId); + } + sp.set("systemPropertyValue", value); + delegator.createOrStore(sp); + } + + /** + * Scheduled purge job: deletes {@link SecretAuditLog} rows whose {@code auditTimestamp} is older + * than the {@code secret.audit.retention.days} SystemProperty (default 365 days). Rows are removed + * in bounded batches of at most {@code secret.audit.purge.batch.size} (default 500) per database + * transaction so the purge never holds a long-running table lock. + * + *

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.

+ */ + public static Map purgeExpiredSecretAuditLogs(DispatchContext dctx, + Map context) { + Delegator delegator = dctx.getDelegator(); + + int retentionDays = 365; + int batchSize = 500; + try { + String v = EntityUtilProperties.getPropertyValue("security", "secret.audit.retention.days", delegator); + if (UtilValidate.isNotEmpty(v)) { + retentionDays = Integer.parseInt(v.trim()); + } + } catch (NumberFormatException e) { + Debug.logWarning("secret.audit.retention.days is not a valid integer — using default 365", MODULE); + } + try { + String v = EntityUtilProperties.getPropertyValue("security", "secret.audit.purge.batch.size", delegator); + if (UtilValidate.isNotEmpty(v)) { + batchSize = Integer.parseInt(v.trim()); + } + } catch (NumberFormatException e) { + Debug.logWarning("secret.audit.purge.batch.size is not a valid integer — using default 500", MODULE); + } + + Timestamp cutoff = Timestamp.from(Instant.now().minus(Duration.ofDays(retentionDays))); + EntityCondition expired = EntityCondition.makeCondition( + "auditTimestamp", EntityOperator.LESS_THAN, cutoff); + + long totalPurged = 0; + try { + while (true) { + List batch = EntityQuery.use(delegator) + .select("secretAuditLogId") + .from("SecretAuditLog") + .where(expired) + .maxRows(batchSize) + .queryList(); + if (batch.isEmpty()) { + break; + } + delegator.removeAll(batch); + totalPurged += batch.size(); + if (batch.size() < batchSize) { + break; + } + } + } catch (GenericEntityException e) { + Debug.logError(e, "purgeExpiredSecretAuditLogs: batch delete failed after " + + totalPurged + " rows", MODULE); + return ServiceUtil.returnError("Purge failed after " + totalPurged + " rows: " + + e.getMessage()); + } + + Debug.logInfo("purgeExpiredSecretAuditLogs: purged " + totalPurged + + " SecretAuditLog row(s) older than " + retentionDays + " days", MODULE); + Map result = ServiceUtil.returnSuccess( + "Purged " + totalPurged + " SecretAuditLog row(s) older than " + retentionDays + " days"); + result.put("purgedCount", totalPurged); + return result; + } + + 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", lookupKey); + } + systemProperty.set("lastRotatedDate", UtilDateTime.nowTimestamp()); + delegator.createOrStore(systemProperty); + } + + /** + * Searches all loaded OFBiz components for {@code config/.properties}, + * 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 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 + * 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, SecretValueResolver.MARKER_NAME + "(" + lookupKey + ")"); + UtilCache.clearCachesThatStartWith("properties.UtilProperties"); + Debug.logInfo("Set " + systemPropertyId + "=" + SecretValueResolver.MARKER_NAME + "(" + lookupKey + ") in " + + propsFile.getPath() + " and refreshed property caches", MODULE); + } + + /** + * 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 { + String linePrefix = propertyId + "="; + for (String line : Files.readAllLines(file.toPath(), StandardCharsets.UTF_8)) { + if (line.startsWith(linePrefix)) { + String value = line.substring(linePrefix.length()).trim(); + String markerPrefix = SecretValueResolver.MARKER_NAME + "("; + if (value.startsWith(markerPrefix) && value.endsWith(")")) { + return value.substring(markerPrefix.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))) { + atomicWrite(file.toPath(), lines); + } + } 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); + } + 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); + } + + 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 envVar = ConfigCryptoUtil.MASTER_KEY_ENV_VAR; + String masterKey = System.getenv(envVar); + if (UtilValidate.isEmpty(masterKey)) { + 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/SecretAuditContainerTest.java b/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretAuditContainerTest.java new file mode 100644 index 00000000000..0a786a7bdcc --- /dev/null +++ b/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretAuditContainerTest.java @@ -0,0 +1,95 @@ +/******************************************************************************* + * 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.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import java.util.List; + +import org.apache.ofbiz.base.container.ContainerException; +import org.apache.ofbiz.base.secret.SecretValueResolver; +import org.junit.After; +import org.junit.Test; + +/** + * Unit tests for {@link SecretAuditContainer} — verifies that {@code start()} wires + * {@link SecretAuditQueue#INSTANCE} into {@link SecretValueResolver} and that {@code init()} + * handles absent container configuration gracefully (falls back to delegator name "default"). + * + *

These tests do not exercise the drain thread or database writes; they only verify the + * registration/deregistration contract and the delegator-name fallback path.

+ */ +public class SecretAuditContainerTest { + + @After + public void clearAuditSink() { + SecretValueResolver.setAuditSink(null); + } + + @Test + public void startRegistersSecretAuditQueueAsSink() throws ContainerException { + SecretValueResolver.setAuditSink(null); + SecretAuditContainer container = new SecretAuditContainer(); + container.init(List.of(), "secret-audit-container", null); + + container.start(); + + assertSame("start() must register SecretAuditQueue.INSTANCE as the active audit sink", + SecretAuditQueue.INSTANCE, SecretValueResolver.getAuditSink()); + } + + @Test + public void startSetsNonNullAuditSink() throws ContainerException { + SecretValueResolver.setAuditSink(null); + SecretAuditContainer container = new SecretAuditContainer(); + container.init(List.of(), "secret-audit-container", null); + + container.start(); + + assertNotNull("start() must leave a non-null audit sink registered with SecretValueResolver", + SecretValueResolver.getAuditSink()); + } + + @Test + public void initWithUnknownContainerNameFallsBackToDefault() throws ContainerException { + // ContainerConfig.getConfiguration() returns null for an unregistered name (test environment). + // The container must not throw and must still register the sink using the "default" delegator. + SecretValueResolver.setAuditSink(null); + SecretAuditContainer container = new SecretAuditContainer(); + container.init(List.of(), "non-existent-container", null); + + container.start(); + + assertNotNull("start() must succeed even when ContainerConfig returns null (uses 'default' delegator fallback)", + SecretValueResolver.getAuditSink()); + } + + @Test + public void auditSinkIsNullBeforeStart() throws ContainerException { + SecretValueResolver.setAuditSink(null); + // Merely calling init() must not register the sink — that belongs to start(). + SecretAuditContainer container = new SecretAuditContainer(); + container.init(List.of(), "secret-audit-container", null); + + assertNull("init() alone must not register the audit sink — that is start()'s responsibility", + SecretValueResolver.getAuditSink()); + } +} diff --git a/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretAuditQueueTest.java b/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretAuditQueueTest.java new file mode 100644 index 00000000000..6f1945bd30a --- /dev/null +++ b/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretAuditQueueTest.java @@ -0,0 +1,94 @@ +/******************************************************************************* + * 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.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +/** + * Unit tests for {@link SecretAuditQueue} — verifies the audit-sink callbacks + * build correctly shaped events and that the queue handles overflow and delegator-name + * reconfiguration without throwing. + * + *

The background drain thread never fires in this environment because + * {@code DelegatorFactory.getDelegator("default")} returns {@code null} without a running + * OFBiz entity layer, so the queue guard exits early and events remain queued. This means + * queue-size assertions are stable within a single test run.

+ */ +public class SecretAuditQueueTest { + + @Before + public void clearQueue() { + // The queue is a singleton shared across tests. Clear it so size-based assertions + // are not affected by events enqueued by earlier tests (the drain thread is a no-op + // in the test environment because DelegatorFactory returns null without a running OFBiz). + SecretAuditQueue.INSTANCE.clearForTesting(); + } + + @Test + public void onFetchSuccessEnqueuesEvent() { + int before = SecretAuditQueue.INSTANCE.queueSize(); + SecretAuditQueue.INSTANCE.onFetch("jdbc-password.default", "PROVIDER_CALL", "SUCCESS", null); + assertTrue("onFetch(SUCCESS) must add exactly one event to the queue", + SecretAuditQueue.INSTANCE.queueSize() > before); + } + + @Test + public void onFetchFailureWithErrorCategoryEnqueuesEvent() { + int before = SecretAuditQueue.INSTANCE.queueSize(); + SecretAuditQueue.INSTANCE.onFetch("jdbc-password.default", "PROVIDER_CALL", "FAILURE", "NETWORK_TIMEOUT"); + assertTrue("onFetch(FAILURE) must add exactly one event to the queue", + SecretAuditQueue.INSTANCE.queueSize() > before); + } + + @Test + public void onCacheHitEnqueuesEvent() { + int before = SecretAuditQueue.INSTANCE.queueSize(); + SecretAuditQueue.INSTANCE.onCacheHit("jdbc-password.default"); + assertTrue("onCacheHit() must add exactly one event to the queue", + SecretAuditQueue.INSTANCE.queueSize() > before); + } + + @Test + public void onRotationPollEnqueuesEvent() { + int before = SecretAuditQueue.INSTANCE.queueSize(); + SecretAuditQueue.INSTANCE.onRotationPoll("SUCCESS", null); + assertTrue("onRotationPoll(SUCCESS) must add exactly one event to the queue", + SecretAuditQueue.INSTANCE.queueSize() > before); + } + + @Test + public void setDelegatorNameDoesNotThrow() { + // Package-private — callable from this package. Verifies that reconfiguring the + // delegator name at startup does not throw for any non-null value. + SecretAuditQueue.INSTANCE.setDelegatorName("myCustomDelegator"); + SecretAuditQueue.INSTANCE.setDelegatorName("default"); // restore to default + } + + @Test + public void queueDoesNotThrowWhenAtCapacity() { + // Flood the queue beyond its 2000-event capacity; overflow must be silently dropped. + for (int i = 0; i < 2200; i++) { + SecretAuditQueue.INSTANCE.onFetch("flood-key-" + i, "PROVIDER_CALL", "SUCCESS", null); + } + // If we reach here, the queue correctly dropped overflow events without throwing. + } +} 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/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..d723357cc6f --- /dev/null +++ b/framework/webtools/src/test/java/org/apache/ofbiz/webtools/secret/SecretManagerServicesTest.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.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.anyString; +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.security.Security; +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. + // The permission check runs first; stub security to allow SECRET_MAINT so we reach + // the autosync-disabled guard and confirm the early-exit success response. + Security security = mock(Security.class); + when(security.hasPermission(anyString(), (GenericValue) any())).thenReturn(true); + when(dctx.getSecurity()).thenReturn(security); + + 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); + // Feature 2 adds a ROTATION_POLL SecretAuditLogger.log() call after the loop. + // Return "test" so SecretAuditLogger skips the DB write in the test guard. + when(delegator.getDelegatorBaseName()).thenReturn("test"); + + 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; + } +} 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.
  • ${uiLabelMap.PageTitleEntityImport}
  • ${uiLabelMap.PageTitleEntityImportDir}
  • ${uiLabelMap.PageTitleEntityImportReaders}
  • +
  • ${uiLabelMap.WebtoolsSecretManagerTools}

  • +
  • ${uiLabelMap.WebtoolsEncryptValue}
  • <#if security.hasPermission("SERVICE_MAINT", session)>
  • ${uiLabelMap.WebtoolsServiceEngineTools}

  • diff --git a/framework/webtools/template/secret/EncryptValue.ftl b/framework/webtools/template/secret/EncryptValue.ftl new file mode 100644 index 00000000000..a818decb644 --- /dev/null +++ b/framework/webtools/template/secret/EncryptValue.ftl @@ -0,0 +1,337 @@ +<#-- +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. +--> + +

    + ${uiLabelMap.WebtoolsSecretActiveProvider}: ${activeSecretProvider!} + <#if providerHealthy?has_content> + <#if providerHealthy> + — ${uiLabelMap.WebtoolsSecretProviderReachable} + <#else> + — ${uiLabelMap.WebtoolsSecretProviderUnreachable} + + +

    +<#if activeSettings?has_content> +

    Active Configuration

    + + + <#list activeSettings as entry> + + + +
    ${entry[0]}${entry[1]}
    + +

    + ${uiLabelMap.WebtoolsSecretAuditLogLink} +

    +

    ${uiLabelMap.WebtoolsEncryptValueInfo}

    +
      +
    • ${uiLabelMap.WebtoolsSecretSystemPropertyRequiredInfo}
    • +
    • ${uiLabelMap.WebtoolsSecretPasswordsFileRequiredInfo}
    • +
    + +<#assign inlineEventList = eventMessageList![]/> +<#assign inlineErrorList = errorMessageList![]/> +<#if inlineEventList?has_content> +
    + <#list inlineEventList as msg>

    ${msg}

    +
    + +<#if inlineErrorList?has_content> +
    + <#list inlineErrorList as err>

    ${err}

    +
    + + +
    + +<#assign prevTarget = parameters.secretTarget!"SYSTEM_PROPERTY"/> +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + +
    + + + +
    + + + +
    + + + +
    ${uiLabelMap.WebtoolsSecretLookupKeyInfo}
    +
    + + + +
    + + + +
    + +
    +
    + + +
    + +

    ${uiLabelMap.WebtoolsSecretCsvUploadInfo}

    +
      +
    • ${uiLabelMap.WebtoolsSecretCsvColumns}
    • +
    • ${uiLabelMap.WebtoolsSecretCsvTargetInfo}
    • +
    + +
    + + + + + + + + + + + +
    + + + +
    + +
    +
    + +
    + +

    ${uiLabelMap.WebtoolsSecretFlushCacheInfo}

    +
    + +
    + +
    + +

    ${uiLabelMap.WebtoolsSecretReloadProviderInfo}

    +
    + +
    + +
    + +

    ${uiLabelMap.WebtoolsSecretSyncInfo}

    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + +
    + + + +
    + + + +
    + + + +
    + +
    +
    + +
    + +

    ${uiLabelMap.WebtoolsSecretTestConnectionInfo}

    +
    + + + + + + + + + + + +
    + + + +
    + +
    +
    + +
    + +

    ${uiLabelMap.WebtoolsSecretUsageStatsInfo}

    +

    + ${uiLabelMap.WebtoolsSecretUsageTotalHits}: ${usageSummary.totalHits!0}  |  + ${uiLabelMap.WebtoolsSecretUsageTotalMisses}: ${usageSummary.totalMisses!0}  |  + ${uiLabelMap.WebtoolsSecretUsageTotalLookups}: ${usageSummary.totalLookups!0}  |  + ${uiLabelMap.WebtoolsSecretUsageCachedKeys}: ${usageSummary.cachedKeys!0} +   ${uiLabelMap.WebtoolsSecretUsageStatsFullView} +

    diff --git a/framework/webtools/template/secret/SecretAuditLog.ftl b/framework/webtools/template/secret/SecretAuditLog.ftl new file mode 100644 index 00000000000..b5be6c20012 --- /dev/null +++ b/framework/webtools/template/secret/SecretAuditLog.ftl @@ -0,0 +1,239 @@ +<#-- +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. +--> + +<#-- K8s injected-mode notice: vault-level FETCH events live in the operator log, not here --> +<#if isK8sMode!false> +
    + ${uiLabelMap.WebtoolsSecretAuditK8sNoticeTitle}: + ${uiLabelMap.WebtoolsSecretAuditK8sNoticeBody} +
    + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ${uiLabelMap.WebtoolsSecretAuditFilterUser}${uiLabelMap.WebtoolsSecretAuditFilterAction} + +
    ${uiLabelMap.WebtoolsSecretAuditFilterOutcome} + + ${uiLabelMap.WebtoolsSecretAuditFilterKey}
    ${uiLabelMap.WebtoolsSecretAuditFilterFrom}${uiLabelMap.WebtoolsSecretAuditFilterTo}
    ${uiLabelMap.WebtoolsSecretAuditFilterProvider} + <#assign providerOptions = [ + {"v": "AwsSecretsManagerProvider", "l": "AWS"}, + {"v": "AzureKeyVaultSecretsProvider", "l": "AZURE"}, + {"v": "HashicorpVaultSecretsProvider", "l": "HASHICORP"}, + {"v": "GcpSecretManagerSecretsProvider", "l": "GCP"}, + {"v": "BitwardenSecretsProvider", "l": "BITWARDEN"}, + {"v": "OnePasswordSecretsProvider", "l": "1PASSWORD"}, + {"v": "EnvVarSecretProvider", "l": "ENV_VAR"}, + {"v": "FileBasedSecretProvider", "l": "FILE"} + ]/> + + ${uiLabelMap.WebtoolsSecretAuditFilterDeployMode} + +
    + ${uiLabelMap.CommonClear} +   + +
    +
    + +<#-- Export CSV — date range is mandatory; the form carries current filter state --> +
    +

    ${uiLabelMap.WebtoolsSecretAuditExportTitle}

    +
    + + + + + + + + + + + + + + +
    ${uiLabelMap.WebtoolsSecretAuditFilterFrom} *${uiLabelMap.WebtoolsSecretAuditFilterTo} * + +
    +

    ${uiLabelMap.WebtoolsSecretAuditExportHint}

    +
    + +<#if (listSize!0) gt 0> +

    + ${uiLabelMap.WebtoolsSecretAuditShowing} ${lowIndex!0}–${highIndex!0} ${uiLabelMap.WebtoolsSecretAuditOf} ${listSize!0} +  |  + <#if (viewIndex > 0)> + « ${uiLabelMap.CommonPrevious} +   + + <#if (highIndex < listSize)> + ${uiLabelMap.CommonNext} » + +

    + + + + + + + + + + + + + + + + + + + + + <#list auditRows as row> + class="alternate-row"> + + + + + class="alert">${row.outcome!} + + + + + + + + + + + +
    ${uiLabelMap.WebtoolsSecretAuditTimestamp}${uiLabelMap.WebtoolsSecretAuditUser}${uiLabelMap.WebtoolsSecretAuditClientIp}${uiLabelMap.WebtoolsSecretAuditAction}${uiLabelMap.WebtoolsSecretAuditOutcome}${uiLabelMap.WebtoolsSecretAuditErrorCategory}${uiLabelMap.WebtoolsSecretAuditKey}${uiLabelMap.WebtoolsSecretAuditTarget}${uiLabelMap.WebtoolsSecretAuditAccessMode}${uiLabelMap.WebtoolsSecretAuditProvider}${uiLabelMap.WebtoolsSecretAuditDeployMode}${uiLabelMap.WebtoolsSecretAuditResourceId}${uiLabelMap.WebtoolsSecretAuditPropertyId}
    ${(row.auditTimestamp)!}${row.userLoginId!}${row.clientIpAddress!}${row.action!}${row.errorCategory!}${row.secretKeyRef!}${row.secretTarget!}${row.accessMode!}${row.providerType!}${row.deploymentMode!}${row.systemResourceId!}${row.systemPropertyId!}
    +<#else> +

    ${uiLabelMap.WebtoolsSecretAuditNoRows}

    + + +<#-- Retention Settings panel (read-only unless SECRET_MAINT, which is handled server-side) --> +
    +

    ${uiLabelMap.WebtoolsSecretAuditRetentionTitle}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ${uiLabelMap.WebtoolsSecretAuditRetentionDays}${retentionDays!365} ${uiLabelMap.WebtoolsSecretAuditRetentionDaysSuffix}
    ${uiLabelMap.WebtoolsSecretAuditRetentionBatch}${purgeBatchSize!500}
    ${uiLabelMap.WebtoolsSecretAuditRetentionNextRun}${nextPurgeRun!'(not scheduled or already ran)'}
    ${uiLabelMap.WebtoolsSecretAuditRetentionFetchEvents}${(fetchEventsEnabled!false)?string('ENABLED','DISABLED (default)')}
    ${uiLabelMap.WebtoolsSecretAuditRetentionCacheHits}${(cacheHitsEnabled!false)?string('ENABLED','DISABLED (default)')}
    ${uiLabelMap.WebtoolsSecretAuditDeployMode}${deploymentMode!'DIRECT'}
    +<#if hasSecretMaint!false> +
    + + + + + + + + + + + + + + + +
    ${uiLabelMap.WebtoolsSecretAuditRetentionDays} + + ${uiLabelMap.WebtoolsSecretAuditRetentionDaysSuffix} +
    ${uiLabelMap.WebtoolsSecretAuditRetentionBatch}
    +
    +<#else> +

    ${uiLabelMap.WebtoolsSecretAuditRetentionHint}

    + diff --git a/framework/webtools/template/secret/SecretUsageStats.ftl b/framework/webtools/template/secret/SecretUsageStats.ftl new file mode 100644 index 00000000000..d9251228ecf --- /dev/null +++ b/framework/webtools/template/secret/SecretUsageStats.ftl @@ -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. +--> + +

    + ← ${uiLabelMap.WebtoolsSecretManagerTools} +

    + +

    + ${uiLabelMap.WebtoolsSecretUsageTotalHits}: ${usageSummary.totalHits!0}  |  + ${uiLabelMap.WebtoolsSecretUsageTotalMisses}: ${usageSummary.totalMisses!0}  |  + ${uiLabelMap.WebtoolsSecretUsageTotalLookups}: ${usageSummary.totalLookups!0}  |  + ${uiLabelMap.WebtoolsSecretUsageCachedKeys}: ${usageSummary.cachedKeys!0} +

    + +<#if usageReportRows?has_content> + + + + + + + + + + + + <#list usageReportRows as row> + + + + + + + + + +
    ${uiLabelMap.WebtoolsSecretUsageKey}${uiLabelMap.WebtoolsSecretUsageHits}${uiLabelMap.WebtoolsSecretUsageMisses}${uiLabelMap.WebtoolsSecretUsageTotal}${uiLabelMap.WebtoolsSecretUsageLastAccessed}
    ${row[0]}${row[1]}${row[2]}${row[3]}${row[4]}
    +<#else> +

    ${uiLabelMap.WebtoolsSecretUsageNoStats}

    + + +
    + +

    ${uiLabelMap.WebtoolsSecretUsageStatsResetInfo}

    +
    + +
    diff --git a/framework/webtools/webapp/webtools/WEB-INF/controller.xml b/framework/webtools/webapp/webtools/WEB-INF/controller.xml index e6ce70effaa..dcdf7ab396b 100644 --- a/framework/webtools/webapp/webtools/WEB-INF/controller.xml +++ b/framework/webtools/webapp/webtools/WEB-INF/controller.xml @@ -466,6 +466,80 @@ under the License. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -727,6 +801,9 @@ 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 new file mode 100644 index 00000000000..6b4cf6b09d1 --- /dev/null +++ b/framework/webtools/widget/SecretManagerScreens.xml @@ -0,0 +1,112 @@ + + + + + +
    + + + +