diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 56a857750..d1f7caacf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,11 +23,17 @@ updates: - package-ecosystem: "gradle" directories: - "/ai-agent-skills" + - "/aws-secrets-provider" + - "/azure-keyvault-secrets-provider" - "/birt" + - "/bitwarden-secrets-provider" - "/example" - "/firstdatapaymentgateway" + - "/gcp-secretmanager-secrets-provider" + - "/hashicorp-vault-secrets-provider" - "/ldap" - "/lucene" + - "/onepassword-secrets-provider" - "/pricat" schedule: interval: "daily" diff --git a/secretshub/build.gradle b/secretshub/build.gradle new file mode 100644 index 000000000..c7bdc1b6c --- /dev/null +++ b/secretshub/build.gradle @@ -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. + */ + +// Combined dependencies for all 7 bundled SecretProvider implementations. +// Only the provider selected via secret.provider.active in secretshub.properties +// is ever constructed at runtime (see ActiveSecretProvider), but all 7 SDKs must +// be on the classpath at build time since any one of them may be chosen. +// +// AWS SDK for Java v2 — Apache License 2.0 — https://github.com/aws/aws-sdk-java-v2 +// Azure SDK for Java — Key Vault Secrets (MIT License) — https://github.com/Azure/azure-sdk-for-java +// Google Cloud Secret Manager Java client (Apache License 2.0) — https://github.com/googleapis/java-secretmanager +// HashiCorp Vault Java Driver — Apache License 2.0 — https://github.com/jopenlibs/vault-java-driver +// +// Bitwarden, env-var, and 1Password contribute no third-party dependencies here — +// JDK java.net.http.HttpClient only. +dependencies { + pluginLibsCompile 'software.amazon.awssdk:secretsmanager:2.26.31' + pluginLibsCompile 'software.amazon.awssdk:url-connection-client:2.26.31' + + pluginLibsCompile 'com.azure:azure-security-keyvault-secrets:4.8.0' + pluginLibsCompile 'com.azure:azure-identity:1.12.0' + // Azure's HTTP client (azure-core-http-netty / reactor-netty) lists io.netty:netty-tcnative-boringssl-static + // as an OPTIONAL transitive dependency, so Gradle never downloads it and Netty falls back to the plain JDK + // SSLEngine for TLS. That fallback is functionally correct (confirmed against a real Azure Key Vault) — the + // only effect of its absence is losing OpenSSL-accelerated TLS throughput, which only matters at production + // scale, not for local development. Netty logs the missing-library fallback at DEBUG (see the io.netty / + // reactor / com.azure logger entries in framework/base/config/log4j2.xml); it is not an error. + // To opt in to native TLS acceleration, uncomment the line below. The version is already pinned by the + // existing dependency graph (see `./gradlew dependencies -p plugins/secretshub`), so no version guesswork is + // needed; it resolves to the same "uber" jar bundling native binaries for linux-x86_64, linux-aarch_64, + // osx-x86_64, osx-aarch_64, and windows-x86_64, so it stays portable across dev machines. Regenerate the + // lockfile after uncommenting (rm gradle.lockfile && ./gradlew :plugins:secretshub:dependencies --write-locks). + // pluginLibsRuntime 'io.netty:netty-tcnative-boringssl-static:2.0.65.Final' + + pluginLibsCompile 'com.google.cloud:google-cloud-secretmanager:2.36.0' + + pluginLibsCompile 'io.github.jopenlibs:vault-java-driver:5.4.0' +} + +// AWS SDK v2 and others bundle several items that conflict with OFBiz's global exclusions +configurations.all { + exclude group: 'commons-logging', module: 'commons-logging' +} + +dependencyLocking { + lockAllConfigurations() +} diff --git a/secretshub/config/aws-secrets-manager.properties b/secretshub/config/aws-secrets-manager.properties new file mode 100644 index 000000000..7839eaf74 --- /dev/null +++ b/secretshub/config/aws-secrets-manager.properties @@ -0,0 +1,79 @@ +############################################################################### +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +############################################################################### + +#### +# AWS Secrets Manager — SecretProvider configuration +# +# Authentication: +# - If both aws.secretsmanager.access.key.id and aws.secretsmanager.secret.access.key +# are set below, those static credentials are used. +# - Otherwise, the AWS Default Credential Provider Chain is used: +# 1. Environment variables: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY +# 2. Java system properties: aws.accessKeyId / aws.secretAccessKey +# 3. AWS credential profiles file (~/.aws/credentials) +# 4. EC2 / ECS instance profile / IAM role (recommended for production) +# 5. AWS SSO +#### + +# AWS region for the Secrets Manager endpoint. +# Leave empty to let the SDK resolve from the environment (recommended for EC2/ECS). +aws.secretsmanager.region=us-east-1 + +# Optional static credentials. Leave both empty to use the AWS Default Credential +# Provider Chain (recommended for EC2/ECS/EKS via instance/task IAM roles). +# aws.secretsmanager.secret.access.key may be encrypted with ENC(...), see +# ConfigCryptoUtil and the encryptDbPassword Gradle task. +aws.secretsmanager.access.key.id= +aws.secretsmanager.secret.access.key= + +# How long (in seconds) to cache a resolved secret value in memory before re-fetching. +# Default: 3600 (1 hour). Set to 0 to disable caching (not recommended in production). +aws.secretsmanager.cache.ttl.seconds=3600 + +# Optional prefix prepended to every OFBiz secret key before the AWS lookup. +# Example: with prefix "myapp/prod/" the key "jdbc-password.ofbiz" becomes +# "myapp/prod/jdbc-password.ofbiz" in AWS Secrets Manager. +aws.secretsmanager.secret.name.prefix= + +# If the secret in AWS is a JSON object (e.g. {"password":"s3cr3t","username":"dbuser"}), +# set this to the field name that holds the actual secret value (e.g. "password"). +# Leave empty to use the raw secret string as-is. +aws.secretsmanager.json.field= + +# Optional endpoint override. Useful for local testing with LocalStack. +# Example: http://localhost:4566 +aws.secretsmanager.endpoint.override= + +# If AWS Secrets Manager is unreachable (e.g. outage, network issue), fall back to +# the value configured for the same key in framework/base/config/passwords.properties. +# A warning is logged whenever this fallback is used. Values in passwords.properties +# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword +# Gradle task. +# Default: true +aws.secretsmanager.fallback.enabled=true + +# Optional per-key overrides for deployments where the OFBiz logical secret key +# (e.g. "jdbc-password.mysql-ofbiz", documented in SECRET_KEYS.md) cannot be stored +# verbatim as the AWS Secrets Manager secret name (e.g. naming restrictions in a +# given AWS account/region setup). The logical key on the left never changes; only +# the AWS-side secret name on the right needs to satisfy AWS's naming rules. +# Format: key.alias.= +# Example: +# key.alias.jdbc-password.mysql-ofbiz=prod/ofbiz/mysql_db_password +# Leave unset (the default) for keys that AWS will accept verbatim. diff --git a/secretshub/config/azure-keyvault.properties b/secretshub/config/azure-keyvault.properties new file mode 100644 index 000000000..4202494c0 --- /dev/null +++ b/secretshub/config/azure-keyvault.properties @@ -0,0 +1,56 @@ +############################################################################### +# Azure Key Vault — SecretProvider configuration +# +# To activate this provider: +# 1. Set enabled="true" in azure-keyvault-secrets-provider/ofbiz-component.xml +# 2. Set enabled="false" on every other *-secrets-provider plugin +############################################################################### + +# Full URL of your Azure Key Vault (required) +# Example: https://my-vault.vault.azure.net +azure.keyvault.url= + +# Authentication method: +# default — Uses DefaultAzureCredential: tries Managed Identity, env vars, +# Azure CLI, Visual Studio Code, etc. in order. Recommended for +# Azure-hosted deployments (AKS, App Service, VMs with Managed Identity). +# client_secret — Authenticates as a service principal using tenant + client credentials. +# Use for on-premise or non-Azure deployments. +azure.auth.method=default + +# Required only when azure.auth.method=client_secret +azure.tenant.id= +azure.client.id= +azure.client.secret= + +# Optional prefix prepended to every secret name before lookup. +# Example: "prod-" makes key "db" → "prod-db" +azure.secret.name.prefix= + +# Azure Key Vault secret names may only contain letters, digits and hyphens. +# OFBiz keys like "jdbc-password.mysql-ofbiz" contain a dot, which is invalid. +# Set this to the replacement character (default: -) so the dot is substituted. +# Example: "jdbc-password.mysql-ofbiz" → "jdbc-password-mysql-ofbiz" +# Set to empty to disable replacement (only do this if your keys have no dots). +azure.secret.name.dot.replacement=- + +# Optional per-key overrides for keys where the dot-replacement convention above still +# doesn't produce an acceptable Azure Key Vault secret name. The logical key on the left +# never changes; only the Azure-side secret name on the right needs to satisfy Azure's +# naming rules. Checked before dot-replacement is applied. +# Format: key.alias.= +# Example: +# key.alias.jdbc-password.mysql-ofbiz=prod-ofbiz-mysql-db-password +# Leave unset (the default) for keys the dot-replacement convention already handles. + +# In-memory cache TTL in seconds (default: 3600 = 1 hour). +# Set to 0 to disable caching (fetches from Azure on every call). +azure.cache.ttl.seconds=3600 + +# If Azure Key Vault is unreachable (e.g. outage, network issue), fall back to +# the value configured for the same key in framework/base/config/passwords.properties. +# A warning is logged whenever this fallback is used. Values in passwords.properties +# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword +# Gradle task. +# Default: true +azure.fallback.enabled=true diff --git a/secretshub/config/bitwarden-secrets.properties b/secretshub/config/bitwarden-secrets.properties new file mode 100644 index 000000000..b1e992820 --- /dev/null +++ b/secretshub/config/bitwarden-secrets.properties @@ -0,0 +1,75 @@ +############################################################################### +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +############################################################################### + +#### +# Bitwarden Secrets Manager — SecretProvider configuration +# +# Requires a Bitwarden Secrets Manager machine account access token. +# Create one in the Bitwarden SM console under Machine Accounts. +# +# The access token embeds an AES-256 symmetric key used to decrypt +# secret names and values client-side (end-to-end encryption). +# +# NOTE: Only ONE SecretProvider plugin may be active at a time. +# Deploy only the plugin that matches your environment. +#### + +# Bitwarden Secrets Manager REST API base URL. +# Use https://api.bitwarden.eu for EU cloud, or your self-hosted URL. +bitwarden.api.url=https://api.bitwarden.com + +# Bitwarden Identity Service URL (used for machine account OAuth token exchange). +bitwarden.identity.url=https://identity.bitwarden.com + +# Machine account access token. +# Format: 0..: +# Inject at deploy time — do not commit a real token. +bitwarden.access.token= + +# UUID of the organization that owns the secrets. +bitwarden.organization.id= + +# Optional prefix prepended to the OFBiz key when matching the Bitwarden secret key (name). +# Example: with prefix "myapp/" the OFBiz key "jdbc-password.ofbiz" matches a +# Bitwarden secret whose decrypted key is "myapp/jdbc-password.ofbiz". +bitwarden.secret.name.prefix= + +# Optional per-key overrides for deployments where a specific key needs to match a +# Bitwarden secret title that differs from the OFBiz logical key. The logical key on the +# left never changes; only the secret title on the right needs to match what's in Bitwarden. +# Format: key.alias.= +# Example: +# key.alias.jdbc-password.mysql-ofbiz=prod-ofbiz-mysql-db-password +# Leave unset (the default) for keys whose title matches the logical key verbatim. + +# How long (in seconds) to cache resolved secret values before re-fetching. +# Default: 3600 (1 hour). Set to 0 to disable caching. +bitwarden.cache.ttl.seconds=3600 + +# HTTP connect and read timeouts in seconds. +bitwarden.connect.timeout.seconds=5 +bitwarden.read.timeout.seconds=10 + +# If Bitwarden Secrets Manager is unreachable (e.g. outage, network issue), fall back +# to the value configured for the same key in framework/base/config/passwords.properties. +# A warning is logged whenever this fallback is used. Values in passwords.properties +# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword +# Gradle task. +# Default: true +bitwarden.fallback.enabled=true diff --git a/secretshub/config/env-var-secrets.properties b/secretshub/config/env-var-secrets.properties new file mode 100644 index 000000000..06dfc8437 --- /dev/null +++ b/secretshub/config/env-var-secrets.properties @@ -0,0 +1,64 @@ +############################################################################### +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +############################################################################### + +#### +# Environment Variable SecretProvider configuration +# +# Resolves secrets from THIS PROCESS'S OWN environment variables — i.e. +# whatever an external mechanism (Kubernetes External Secrets Operator +# syncing a Secret into envFrom, a Doppler/Infisical "run" wrapper, plain +# systemd EnvironmentFile, etc.) has already placed into the JVM's +# environment before OFBiz started. This provider performs no network +# calls of its own. +# +# Name transform (fixed by default — this is the documented convention so +# any external tool can predict the env var name without reading OFBiz +# source; see external-secret-injection-architecture.md): +# 1. Take the OFBiz secret key, e.g. "jdbc-password.mysql-ofbiz" +# 2. Uppercase it, replace every character that isn't A-Z or 0-9 with "_" +# -> "JDBC_PASSWORD_MYSQL_OFBIZ" +# 3. Prepend envvar.name.prefix +# -> "OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ" +# +# NOTE: Only ONE SecretProvider plugin may be active at a time. +# Deploy only the plugin that matches your environment. +#### + +# Prefix prepended to the transformed key when looking up the environment +# variable. Change only if OFBIZ_ collides with something else in +# your deployment's environment. +envvar.name.prefix=OFBIZ_ + +# Optional per-key overrides for deployments where a specific key needs to resolve to +# an explicit environment variable name instead of the fixed transform above (e.g. a +# naming collision with something else already in the process environment). The logical +# key on the left never changes; the value on the right is used verbatim as the +# environment variable name (the prefix above is NOT re-applied to it). +# Format: key.alias.= +# Example: +# key.alias.jdbc-password.mysql-ofbiz=PROD_OFBIZ_MYSQL_DB_PASSWORD +# Leave unset (the default) for keys the fixed transform already handles. + +# If the expected environment variable is not set, fall back to the value +# configured for the same key in framework/base/config/passwords.properties. +# A warning is logged whenever this fallback is used. Values in +# passwords.properties may be encrypted with ENC(...), see ConfigCryptoUtil +# and the generateDBPassword/generateEncryptedSecret Gradle tasks. +# Default: true +envvar.fallback.enabled=true diff --git a/secretshub/config/gcp-secret-manager.properties b/secretshub/config/gcp-secret-manager.properties new file mode 100644 index 000000000..cc546c40a --- /dev/null +++ b/secretshub/config/gcp-secret-manager.properties @@ -0,0 +1,51 @@ +############################################################################### +# GCP Secret Manager - SecretProvider configuration +# +# To activate this provider: +# 1. Set enabled="true" in gcp-secretmanager-secrets-provider/ofbiz-component.xml +# 2. Set enabled="false" on every other *-secrets-provider plugin +############################################################################### + +# GCP project ID that owns the secrets (required) +# Example: my-gcp-project +gcp.project.id= + +# Path to a service account JSON key file. +# Leave empty to use Application Default Credentials (ADC) - recommended when +# running on GCP (GKE, GCE, Cloud Run, App Engine) or with gcloud auth set up. +gcp.credentials.file= + +# Optional prefix prepended to every secret name before lookup. +# Useful for environment namespacing, e.g. "prod/" makes key "db" -> "prod/db". +gcp.secret.name.prefix= + +# GCP Secret Manager secret version to access (default: latest) +gcp.secret.version=latest + +# GCP secret names may only contain letters, digits, hyphens and underscores. +# OFBiz keys like "jdbc-password.mysql-ofbiz" contain a dot, which is invalid. +# Set this to the replacement character (default: -) so the dot is substituted. +# Example: "jdbc-password.mysql-ofbiz" -> "jdbc-password-mysql-ofbiz" +# Set to empty to disable replacement (only do this if your keys have no dots). +gcp.secret.name.dot.replacement=- + +# Optional per-key overrides for keys where the dot-replacement convention above still +# doesn't produce an acceptable GCP Secret Manager secret name. The logical key on the +# left never changes; only the GCP-side secret name on the right needs to satisfy GCP's +# naming rules. Checked before dot-replacement is applied. +# Format: key.alias.= +# Example: +# key.alias.jdbc-password.mysql-ofbiz=prod-ofbiz-mysql-db-password +# Leave unset (the default) for keys the dot-replacement convention already handles. + +# In-memory cache TTL in seconds (default: 3600 = 1 hour). +# Set to 0 to disable caching (fetches from GCP on every call). +gcp.cache.ttl.seconds=3600 + +# If GCP Secret Manager is unreachable (e.g. outage, network issue), fall back to +# the value configured for the same key in framework/base/config/passwords.properties. +# A warning is logged whenever this fallback is used. Values in passwords.properties +# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword +# Gradle task. +# Default: true +gcp.fallback.enabled=true diff --git a/secretshub/config/hashicorp-vault-secrets.properties b/secretshub/config/hashicorp-vault-secrets.properties new file mode 100644 index 000000000..3d7641053 --- /dev/null +++ b/secretshub/config/hashicorp-vault-secrets.properties @@ -0,0 +1,83 @@ +############################################################################### +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +############################################################################### + +#### +# HashiCorp Vault — SecretProvider configuration +# +# NOTE: Only ONE SecretProvider plugin may be active at a time. +# If multiple provider plugins are on the classpath, the first one +# discovered by java.util.ServiceLoader is used (order is undefined). +# Deploy only the plugin that matches your environment. +#### + +# Vault server address. +hashicorp.vault.address=http://127.0.0.1:8200 + +# Authentication method: token | approle +hashicorp.vault.auth.method=token + +# Token auth: set hashicorp.vault.token (leave empty when using AppRole). +# Never commit real tokens — use an environment variable or a secrets manager +# to inject this value at deploy time. +hashicorp.vault.token= + +# AppRole auth: role_id is non-sensitive; secret_id should be injected at runtime. +hashicorp.vault.approle.role_id= +hashicorp.vault.approle.secret_id= + +# KV secrets engine mount path (the path you used when enabling the engine). +hashicorp.vault.kv.mount=secret + +# KV engine version: 1 or 2 (Vault default is 2 for new mounts). +hashicorp.vault.kv.version=2 + +# Optional prefix prepended to every OFBiz secret key before the Vault lookup. +# Example: with prefix "myapp/prod/" the key "jdbc-password.ofbiz" becomes +# "secret/myapp/prod/jdbc-password.ofbiz" in Vault. +hashicorp.vault.secret.name.prefix= + +# If the secret in Vault holds multiple fields (e.g. {"username":"u","password":"p"}), +# set this to the field name that holds the actual secret value (e.g. "password"). +# Leave empty only when the secret has exactly one field — its value is returned as-is. +hashicorp.vault.field=password + +# Optional per-key overrides for deployments where a specific mount/policy requires a +# different path segment than the OFBiz logical key. The logical key on the left never +# changes; only the Vault-side path segment on the right needs to satisfy that mount's +# naming rules. +# Format: key.alias.= +# Example: +# key.alias.jdbc-password.mysql-ofbiz=prod/ofbiz/mysql_db_password +# Leave unset (the default) for keys Vault will accept verbatim. + +# How long (in seconds) to cache a resolved secret value before re-fetching. +# Default: 3600 (1 hour). Set to 0 to disable caching. +hashicorp.vault.cache.ttl.seconds=3600 + +# Whether to verify the Vault server's TLS certificate. +# Set to false only for local development — never in production. +hashicorp.vault.ssl.verify=true + +# If HashiCorp Vault is unreachable (e.g. outage, network issue), fall back to +# the value configured for the same key in framework/base/config/passwords.properties. +# A warning is logged whenever this fallback is used. Values in passwords.properties +# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword +# Gradle task. +# Default: true +hashicorp.vault.fallback.enabled=true diff --git a/secretshub/config/onepassword.properties b/secretshub/config/onepassword.properties new file mode 100644 index 000000000..373dfc87b --- /dev/null +++ b/secretshub/config/onepassword.properties @@ -0,0 +1,72 @@ +############################################################################### +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +############################################################################### + +#### +# 1Password Connect Server — SecretProvider configuration +# +# Requires a self-hosted 1Password Connect Server. +# See: https://developer.1password.com/docs/connect/ +# +# NOTE: Only ONE SecretProvider plugin may be active at a time. +# Deploy only the plugin that matches your environment. +#### + +# URL of your 1Password Connect Server (no trailing slash). +onepassword.connect.url=http://localhost:8080 + +# Connect Server access token. +# Inject at deploy time — do not commit a real token. +onepassword.connect.token= + +# UUID of the vault to search for secrets. +# Find it in the 1Password app or via: GET /v1/vaults +onepassword.vault.id= + +# The item field label whose value is returned as the secret. +# Default is "password" — matches the standard Login and Password item templates. +onepassword.field=password + +# Optional prefix prepended to the OFBiz key when searching for the 1Password item title. +# Example: with prefix "myapp/" the key "jdbc-password.ofbiz" matches an item titled +# "myapp/jdbc-password.ofbiz" in 1Password. +onepassword.secret.name.prefix= + +# Optional per-key overrides for deployments where a specific key needs to match a +# 1Password item title that differs from the OFBiz logical key. The logical key on the +# left never changes; only the item title on the right needs to match what's in 1Password. +# Format: key.alias.= +# Example: +# key.alias.jdbc-password.mysql-ofbiz=prod-ofbiz-mysql-db-password +# Leave unset (the default) for keys whose title matches the logical key verbatim. + +# How long (in seconds) to cache a resolved secret value before re-fetching. +# Default: 3600 (1 hour). Set to 0 to disable caching. +onepassword.cache.ttl.seconds=3600 + +# HTTP connect and read timeouts in seconds. +onepassword.connect.timeout.seconds=5 +onepassword.read.timeout.seconds=10 + +# If the 1Password Connect Server is unreachable (e.g. outage, network issue), fall +# back to the value configured for the same key in framework/base/config/passwords.properties. +# A warning is logged whenever this fallback is used. Values in passwords.properties +# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword +# Gradle task. +# Default: true +onepassword.fallback.enabled=true diff --git a/secretshub/config/secretshub.properties b/secretshub/config/secretshub.properties new file mode 100644 index 000000000..644c8c601 --- /dev/null +++ b/secretshub/config/secretshub.properties @@ -0,0 +1,36 @@ +############################################################################### +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +############################################################################### + +#### +# secretshub — active SecretProvider switch +# +# This component bundles 7 SecretProvider implementations (AWS Secrets Manager, +# Azure Key Vault, Bitwarden Secrets Manager, environment variables, GCP Secret +# Manager, HashiCorp Vault, 1Password Connect) but only ONE may be active at a +# time. Set the property below to select it; org.apache.ofbiz.secretshub. +# ActiveSecretProvider is the only class ServiceLoader ever constructs, and it +# lazily builds only the provider named here. +# +# After choosing a provider, fill in its own settings in its existing +# *.properties file in this same config/ directory (e.g. aws-secrets-manager.properties +# for aws, hashicorp-vault-secrets.properties for hashicorp-vault, etc.) +# +# Valid values: aws | azure | bitwarden | env-var | gcp | hashicorp-vault | onepassword +#### +secret.provider.active= diff --git a/secretshub/gradle.lockfile b/secretshub/gradle.lockfile new file mode 100644 index 000000000..46b553285 --- /dev/null +++ b/secretshub/gradle.lockfile @@ -0,0 +1,134 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.azure:azure-core-http-netty:1.14.2=pluginLibsCompile +com.azure:azure-core:1.48.0=pluginLibsCompile +com.azure:azure-identity:1.12.0=pluginLibsCompile +com.azure:azure-json:1.1.0=pluginLibsCompile +com.azure:azure-security-keyvault-secrets:4.8.0=pluginLibsCompile +com.azure:azure-xml:1.0.0=pluginLibsCompile +com.fasterxml.jackson.core:jackson-annotations:2.13.5=pluginLibsCompile +com.fasterxml.jackson.core:jackson-core:2.13.5=pluginLibsCompile +com.fasterxml.jackson.core:jackson-databind:2.13.5=pluginLibsCompile +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.5=pluginLibsCompile +com.fasterxml.jackson:jackson-bom:2.13.5=pluginLibsCompile +com.github.stephenc.jcip:jcip-annotations:1.0-1=pluginLibsCompile +com.google.android:annotations:4.1.1.4=pluginLibsCompile +com.google.api.grpc:proto-google-cloud-secretmanager-v1:2.36.0=pluginLibsCompile +com.google.api.grpc:proto-google-cloud-secretmanager-v1beta1:2.36.0=pluginLibsCompile +com.google.api.grpc:proto-google-common-protos:2.34.0=pluginLibsCompile +com.google.api.grpc:proto-google-iam-v1:1.29.0=pluginLibsCompile +com.google.api:api-common:2.26.0=pluginLibsCompile +com.google.api:gax-grpc:2.43.0=pluginLibsCompile +com.google.api:gax-httpjson:2.43.0=pluginLibsCompile +com.google.api:gax:2.43.0=pluginLibsCompile +com.google.auth:google-auth-library-credentials:1.23.0=pluginLibsCompile +com.google.auth:google-auth-library-oauth2-http:1.23.0=pluginLibsCompile +com.google.auto.value:auto-value-annotations:1.10.4=pluginLibsCompile +com.google.cloud:google-cloud-secretmanager:2.36.0=pluginLibsCompile +com.google.code.findbugs:jsr305:3.0.2=pluginLibsCompile +com.google.code.gson:gson:2.10.1=pluginLibsCompile +com.google.errorprone:error_prone_annotations:2.24.1=pluginLibsCompile +com.google.guava:failureaccess:1.0.1=pluginLibsCompile +com.google.guava:guava:32.1.3-jre FAILED=pluginLibsCompile +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=pluginLibsCompile +com.google.http-client:google-http-client-gson:1.44.1=pluginLibsCompile +com.google.http-client:google-http-client:1.44.1=pluginLibsCompile +com.google.j2objc:j2objc-annotations:2.8=pluginLibsCompile +com.google.protobuf:protobuf-java-util:3.25.2=pluginLibsCompile +com.google.protobuf:protobuf-java:3.25.2=pluginLibsCompile +com.google.re2j:re2j:1.7=pluginLibsCompile +com.microsoft.azure:msal4j-persistence-extension:1.3.0=pluginLibsCompile +com.microsoft.azure:msal4j:1.15.0=pluginLibsCompile +com.nimbusds:content-type:2.3=pluginLibsCompile +com.nimbusds:lang-tag:1.7=pluginLibsCompile +com.nimbusds:nimbus-jose-jwt:9.37.3=pluginLibsCompile +com.nimbusds:oauth2-oidc-sdk:11.9.1=pluginLibsCompile +commons-codec:commons-codec:1.17.1=pluginLibsCompile +io.github.jopenlibs:vault-java-driver:5.4.0=pluginLibsCompile +io.grpc:grpc-alts:1.61.1=pluginLibsCompile +io.grpc:grpc-api:1.61.1=pluginLibsCompile +io.grpc:grpc-auth:1.61.1=pluginLibsCompile +io.grpc:grpc-context:1.61.1=pluginLibsCompile +io.grpc:grpc-core:1.61.1=pluginLibsCompile +io.grpc:grpc-googleapis:1.61.1=pluginLibsCompile +io.grpc:grpc-grpclb:1.61.1=pluginLibsCompile +io.grpc:grpc-inprocess:1.61.1=pluginLibsCompile +io.grpc:grpc-netty-shaded:1.61.1=pluginLibsCompile +io.grpc:grpc-protobuf-lite:1.61.1=pluginLibsCompile +io.grpc:grpc-protobuf:1.61.1=pluginLibsCompile +io.grpc:grpc-services:1.61.1=pluginLibsCompile +io.grpc:grpc-stub:1.61.1=pluginLibsCompile +io.grpc:grpc-util:1.61.1=pluginLibsCompile +io.grpc:grpc-xds:1.61.1=pluginLibsCompile +io.netty:netty-buffer:4.1.112.Final=pluginLibsCompile +io.netty:netty-codec-dns:4.1.107.Final=pluginLibsCompile +io.netty:netty-codec-http2:4.1.112.Final=pluginLibsCompile +io.netty:netty-codec-http:4.1.112.Final=pluginLibsCompile +io.netty:netty-codec-socks:4.1.108.Final=pluginLibsCompile +io.netty:netty-codec:4.1.112.Final=pluginLibsCompile +io.netty:netty-common:4.1.112.Final=pluginLibsCompile +io.netty:netty-handler-proxy:4.1.108.Final=pluginLibsCompile +io.netty:netty-handler:4.1.112.Final=pluginLibsCompile +io.netty:netty-resolver-dns-classes-macos:4.1.107.Final=pluginLibsCompile +io.netty:netty-resolver-dns-native-macos:4.1.107.Final=pluginLibsCompile +io.netty:netty-resolver-dns:4.1.107.Final=pluginLibsCompile +io.netty:netty-resolver:4.1.112.Final=pluginLibsCompile +io.netty:netty-tcnative-boringssl-static:2.0.65.Final=pluginLibsCompile +io.netty:netty-tcnative-classes:2.0.65.Final=pluginLibsCompile +io.netty:netty-transport-classes-epoll:4.1.112.Final=pluginLibsCompile +io.netty:netty-transport-classes-kqueue:4.1.108.Final=pluginLibsCompile +io.netty:netty-transport-native-epoll:4.1.108.Final=pluginLibsCompile +io.netty:netty-transport-native-kqueue:4.1.108.Final=pluginLibsCompile +io.netty:netty-transport-native-unix-common:4.1.112.Final=pluginLibsCompile +io.netty:netty-transport:4.1.112.Final=pluginLibsCompile +io.opencensus:opencensus-api:0.31.1=pluginLibsCompile +io.opencensus:opencensus-contrib-http-util:0.31.1=pluginLibsCompile +io.opencensus:opencensus-proto:0.2.0=pluginLibsCompile +io.perfmark:perfmark-api:0.27.0=pluginLibsCompile +io.projectreactor.netty:reactor-netty-core:1.0.43=pluginLibsCompile +io.projectreactor.netty:reactor-netty-http:1.0.43=pluginLibsCompile +io.projectreactor:reactor-core:3.4.36=pluginLibsCompile +javax.annotation:javax.annotation-api:1.3.2=pluginLibsCompile +net.java.dev.jna:jna-platform:5.13.0=pluginLibsCompile +net.java.dev.jna:jna:5.13.0=pluginLibsCompile +net.minidev:accessors-smart:2.5.0=pluginLibsCompile +net.minidev:json-smart:2.5.0=pluginLibsCompile +org.apache.httpcomponents:httpclient:4.5.14=pluginLibsCompile +org.apache.httpcomponents:httpcore:4.4.16=pluginLibsCompile +org.checkerframework:checker-qual:3.42.0=pluginLibsCompile +org.codehaus.mojo:animal-sniffer-annotations:1.23=pluginLibsCompile +org.conscrypt:conscrypt-openjdk-uber:2.5.2=pluginLibsCompile +org.ow2.asm:asm:9.3=pluginLibsCompile +org.reactivestreams:reactive-streams:1.0.4=pluginLibsCompile +org.slf4j:slf4j-api:1.7.36=pluginLibsCompile +org.threeten:threetenbp:1.6.8=pluginLibsCompile +software.amazon.awssdk:annotations:2.26.31=pluginLibsCompile +software.amazon.awssdk:apache-client:2.26.31=pluginLibsCompile +software.amazon.awssdk:auth:2.26.31=pluginLibsCompile +software.amazon.awssdk:aws-core:2.26.31=pluginLibsCompile +software.amazon.awssdk:aws-json-protocol:2.26.31=pluginLibsCompile +software.amazon.awssdk:checksums-spi:2.26.31=pluginLibsCompile +software.amazon.awssdk:checksums:2.26.31=pluginLibsCompile +software.amazon.awssdk:endpoints-spi:2.26.31=pluginLibsCompile +software.amazon.awssdk:http-auth-aws-eventstream:2.26.31=pluginLibsCompile +software.amazon.awssdk:http-auth-aws:2.26.31=pluginLibsCompile +software.amazon.awssdk:http-auth-spi:2.26.31=pluginLibsCompile +software.amazon.awssdk:http-auth:2.26.31=pluginLibsCompile +software.amazon.awssdk:http-client-spi:2.26.31=pluginLibsCompile +software.amazon.awssdk:identity-spi:2.26.31=pluginLibsCompile +software.amazon.awssdk:json-utils:2.26.31=pluginLibsCompile +software.amazon.awssdk:metrics-spi:2.26.31=pluginLibsCompile +software.amazon.awssdk:netty-nio-client:2.26.31=pluginLibsCompile +software.amazon.awssdk:profiles:2.26.31=pluginLibsCompile +software.amazon.awssdk:protocol-core:2.26.31=pluginLibsCompile +software.amazon.awssdk:regions:2.26.31=pluginLibsCompile +software.amazon.awssdk:retries-spi:2.26.31=pluginLibsCompile +software.amazon.awssdk:retries:2.26.31=pluginLibsCompile +software.amazon.awssdk:sdk-core:2.26.31=pluginLibsCompile +software.amazon.awssdk:secretsmanager:2.26.31=pluginLibsCompile +software.amazon.awssdk:third-party-jackson-core:2.26.31=pluginLibsCompile +software.amazon.awssdk:url-connection-client:2.26.31=pluginLibsCompile +software.amazon.awssdk:utils:2.26.31=pluginLibsCompile +software.amazon.eventstream:eventstream:1.0.1=pluginLibsCompile +empty=pluginLibsCompileOnly,pluginLibsRuntime diff --git a/secretshub/ofbiz-component.xml b/secretshub/ofbiz-component.xml new file mode 100644 index 000000000..88a5822f1 --- /dev/null +++ b/secretshub/ofbiz-component.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + diff --git a/secretshub/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java b/secretshub/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java new file mode 100644 index 000000000..853fa358f --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java @@ -0,0 +1,226 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.awssecrets; + +import java.net.URI; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.ofbiz.base.crypto.ConfigCryptoUtil; +import org.apache.ofbiz.base.lang.ThreadSafe; +import org.apache.ofbiz.base.secret.SecretProvider; +import org.apache.ofbiz.base.secret.SecretProviderUtil; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilProperties; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClientBuilder; +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest; +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse; +import software.amazon.awssdk.services.secretsmanager.model.ResourceNotFoundException; + +/** + * {@link SecretProvider} implementation backed by AWS Secrets Manager. + * + *

Authentication is handled by the AWS Default Credential Provider Chain — + * no credentials are stored in this file or in properties files. The preferred + * approach for EC2/ECS/EKS deployments is an IAM instance role or task role, + * which requires zero credential configuration on the server.

+ * + *

Resolved secret values are cached in memory for the duration configured by + * {@code aws.secretsmanager.cache.ttl.seconds} (default 1 hour) to avoid + * per-request API calls. Call {@link #invalidateCache()} to force an immediate + * re-fetch, for example after a manual secret rotation.

+ * + *

Configure via {@code plugins/aws-secrets-provider/config/aws-secrets-manager.properties}.

+ */ +@ThreadSafe +public final class AwsSecretsManagerProvider implements SecretProvider { + + private static final String MODULE = AwsSecretsManagerProvider.class.getName(); + private static final String CONFIG_RESOURCE = "aws-secrets-manager"; + + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + + private final SecretsManagerClient client; + private final long cacheTtlMs; + private final String secretNamePrefix; + private final String jsonField; + private final Map keyAliases; + + private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>(); + + /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */ + public AwsSecretsManagerProvider() { + this(buildClient(), + SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "aws.secretsmanager.cache.ttl.seconds", 3600, MODULE), + prop("aws.secretsmanager.secret.name.prefix", ""), + prop("aws.secretsmanager.json.field", ""), + SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE)); + } + + /** Package-private constructor used by unit tests to inject a mock client. */ + AwsSecretsManagerProvider(SecretsManagerClient client, long cacheTtlMs, + String secretNamePrefix, String jsonField) { + this(client, cacheTtlMs, secretNamePrefix, jsonField, Map.of()); + } + + /** Package-private constructor used by unit tests to inject a mock client and key aliases. */ + AwsSecretsManagerProvider(SecretsManagerClient client, long cacheTtlMs, + String secretNamePrefix, String jsonField, Map keyAliases) { + this.client = client; + this.cacheTtlMs = cacheTtlMs; + this.secretNamePrefix = secretNamePrefix; + this.jsonField = jsonField; + this.keyAliases = keyAliases; + } + + @Override + public String getSecret(String key) throws GeneralException { + String cached = cache.get(key); + if (cached != null) { + return cached; + } + + String physicalKey = keyAliases.getOrDefault(key, key); + String secretName = secretNamePrefix + physicalKey; + String secretValue = fetchFromAws(secretName); + + if (!jsonField.isEmpty()) { + secretValue = extractJsonField(secretValue, secretName); + } + + if (secretValue == null || secretValue.isEmpty()) { + throw new GeneralException("Secret '" + secretName + "' resolved to an empty value"); + } + + secretValue = ConfigCryptoUtil.decryptIfEncrypted(secretValue, secretName); + + cache.put(key, secretValue, cacheTtlMs); + return secretValue; + } + + /** + * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call + * for each key to re-fetch from AWS Secrets Manager. Useful after a manual + * secret rotation to pick up the new value without restarting OFBiz. + */ + @Override + public void invalidateCache() { + cache.clear(); + Debug.logInfo("AwsSecretsManagerProvider: secret cache invalidated", MODULE); + } + + /** Closes the underlying {@link SecretsManagerClient} and releases its connection pool. */ + @Override + public void close() { + client.close(); + Debug.logInfo("AwsSecretsManagerProvider: SecretsManagerClient closed", MODULE); + } + + @Override + public boolean isFallbackEnabled() { + return Boolean.parseBoolean(prop("aws.secretsmanager.fallback.enabled", "true")); + } + + // -- private helpers -- + + private static String prop(String key, String defaultValue) { + return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue); + } + + private String fetchFromAws(String secretName) throws GeneralException { + try { + GetSecretValueResponse response = client.getSecretValue( + GetSecretValueRequest.builder().secretId(secretName).build()); + String value = response.secretString(); + if (value == null) { + throw new GeneralException("Secret '" + secretName + + "' contains binary data; only string secrets are supported"); + } + return value; + } catch (ResourceNotFoundException e) { + throw new GeneralException("Secret '" + secretName + "' not found in AWS Secrets Manager", e); + } catch (SdkException e) { + throw new GeneralException("AWS Secrets Manager error for '" + secretName + "': " + e.getMessage(), e); + } + } + + /** + * Extracts a single string field from a flat JSON secret value. + * AWS typically stores database credentials as {"username":"u","password":"p"}. + */ + private String extractJsonField(String json, String secretName) throws GeneralException { + try { + JsonNode root = JSON_MAPPER.readTree(json); + JsonNode node = root.get(jsonField); + if (node == null || node.isNull()) { + throw new GeneralException( + "JSON field '" + jsonField + "' not found in secret '" + secretName + "'"); + } + return node.asText(); + } catch (JsonProcessingException e) { + throw new GeneralException("Failed to parse JSON for secret '" + secretName + "': " + e.getMessage(), e); + } + } + + private static SecretsManagerClient buildClient() { + String region = prop("aws.secretsmanager.region", ""); + String endpointOverride = prop("aws.secretsmanager.endpoint.override", ""); + String accessKeyId = prop("aws.secretsmanager.access.key.id", ""); + String secretAccessKey = prop("aws.secretsmanager.secret.access.key", ""); + + SecretsManagerClientBuilder builder = SecretsManagerClient.builder() + .httpClient(UrlConnectionHttpClient.builder().build()); + + if (!region.isEmpty()) { + builder.region(Region.of(region)); + } + if (!endpointOverride.isEmpty()) { + builder.endpointOverride(URI.create(endpointOverride)); + } + if (!accessKeyId.isEmpty() && !secretAccessKey.isEmpty()) { + try { + secretAccessKey = ConfigCryptoUtil.decryptIfEncrypted( + secretAccessKey, "aws.secretsmanager.secret.access.key"); + } catch (GeneralException e) { + throw new IllegalStateException( + "Failed to decrypt aws.secretsmanager.secret.access.key: " + e.getMessage(), e); + } + builder.credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKeyId, secretAccessKey))); + Debug.logInfo("AwsSecretsManagerProvider: using static credentials from " + + "aws-secrets-manager.properties", MODULE); + } + + Debug.logInfo("AwsSecretsManagerProvider: initialized" + + (region.isEmpty() ? " (region from environment)" : " region=" + region), MODULE); + return builder.build(); + } + +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java b/secretshub/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java new file mode 100644 index 000000000..a268df676 --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java @@ -0,0 +1,35 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.azurekeyvault; + +/** + * Thin seam over Azure Key Vault reads used by {@link AzureKeyVaultSecretsProvider}. + * Kept package-private so tests can substitute a lambda without connecting to Azure. + */ +@FunctionalInterface +interface AzureKeyVaultReader { + /** + * Retrieves the current value of the named secret. + * + * @param secretName the Key Vault secret name (letters, digits and hyphens only) + * @return the plaintext secret value; never {@code null} + * @throws Exception if the read fails or the secret does not exist + */ + String read(String secretName) throws Exception; +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java new file mode 100644 index 000000000..3d2315c51 --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java @@ -0,0 +1,189 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.azurekeyvault; + +import java.util.Map; + +import com.azure.core.credential.TokenCredential; +import com.azure.identity.ClientSecretCredentialBuilder; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.security.keyvault.secrets.SecretClient; +import com.azure.security.keyvault.secrets.SecretClientBuilder; + +import org.apache.ofbiz.base.crypto.ConfigCryptoUtil; + +import org.apache.ofbiz.base.lang.ThreadSafe; +import org.apache.ofbiz.base.secret.SecretProvider; +import org.apache.ofbiz.base.secret.SecretProviderUtil; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilProperties; + +/** + * {@link SecretProvider} implementation backed by Azure Key Vault. + * + *

Authentication

+ *

Controlled by {@code azure.auth.method}:

+ *
    + *
  • default — Uses {@code DefaultAzureCredential}, which tries + * Managed Identity, environment variables, Azure CLI, and more in sequence. + * Recommended for Azure-hosted deployments (AKS, App Service, VMs with MI).
  • + *
  • client_secret — Authenticates as a service principal using + * {@code azure.tenant.id}, {@code azure.client.id}, and {@code azure.client.secret}. + * Suitable for on-premise or multi-cloud deployments.
  • + *
+ * + *

Secret name mapping

+ *

Azure Key Vault secret names may only contain letters, digits and hyphens. + * OFBiz keys contain dots (e.g. {@code jdbc-password.mysql-ofbiz}), which are invalid. + * Set {@code azure.secret.name.dot.replacement=-} (the default) to replace dots with + * hyphens, so the key maps to {@code jdbc-password-mysql-ofbiz} in the vault.

+ * + *

Per-key naming overrides

+ *

If even the dot-replacement convention above doesn't produce an acceptable name for a + * specific key, set {@code key.alias.=} to store that one key + * under an explicit name instead. The alias is checked first; only keys with no alias entry + * fall through to the dot-replacement convention.

+ * + *

Configure via {@code plugins/azure-keyvault-secrets-provider/config/azure-keyvault.properties}.

+ */ +@ThreadSafe +public final class AzureKeyVaultSecretsProvider implements SecretProvider { + + private static final String MODULE = AzureKeyVaultSecretsProvider.class.getName(); + private static final String CONFIG_RESOURCE = "azure-keyvault"; + + private final AzureKeyVaultReader vaultReader; + private final String secretNamePrefix; + private final String dotReplacement; + private final long cacheTtlMs; + private final Map keyAliases; + + private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>(); + + /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */ + public AzureKeyVaultSecretsProvider() { + this(readerFrom(buildClient()), + prop("azure.secret.name.prefix", ""), + prop("azure.secret.name.dot.replacement", "-"), + SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "azure.cache.ttl.seconds", 3600, MODULE), + SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE)); + } + + /** Package-private constructor used by unit tests to inject an {@link AzureKeyVaultReader} lambda. */ + AzureKeyVaultSecretsProvider(AzureKeyVaultReader vaultReader, String secretNamePrefix, + String dotReplacement, long cacheTtlMs) { + this(vaultReader, secretNamePrefix, dotReplacement, cacheTtlMs, Map.of()); + } + + /** Package-private constructor used by unit tests to inject a vault reader and key aliases. */ + AzureKeyVaultSecretsProvider(AzureKeyVaultReader vaultReader, String secretNamePrefix, + String dotReplacement, long cacheTtlMs, Map keyAliases) { + this.vaultReader = vaultReader; + this.secretNamePrefix = secretNamePrefix; + this.dotReplacement = dotReplacement; + this.cacheTtlMs = cacheTtlMs; + this.keyAliases = keyAliases; + } + + @Override + public String getSecret(String key) throws GeneralException { + String cached = cache.get(key); + if (cached != null) { + return cached; + } + + String physicalKey = keyAliases.get(key); + String sanitizedKey = physicalKey != null ? physicalKey + : dotReplacement.isEmpty() ? key : key.replace(".", dotReplacement); + String secretName = secretNamePrefix + sanitizedKey; + + String value; + try { + value = vaultReader.read(secretName); + } catch (Exception e) { + throw new GeneralException( + "Azure Key Vault read failed for secret '" + secretName + "': " + e.getMessage(), e); + } + + if (value == null || value.isEmpty()) { + throw new GeneralException( + "Azure Key Vault returned empty value for secret '" + secretName + "'"); + } + + value = ConfigCryptoUtil.decryptIfEncrypted(value, secretName); + + cache.put(key, value, cacheTtlMs); + return value; + } + + /** + * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call + * to re-fetch from Azure Key Vault. Useful after a secret rotation. + */ + @Override + public void invalidateCache() { + cache.clear(); + Debug.logInfo("AzureKeyVaultSecretsProvider: secret cache invalidated", MODULE); + } + + @Override + public boolean isFallbackEnabled() { + return Boolean.parseBoolean(prop("azure.fallback.enabled", "true")); + } + + @Override + public void close() { + // Azure SecretClient does not implement Closeable; connection resources are managed by the Azure SDK. + } + + // -- private helpers -- + + private static AzureKeyVaultReader readerFrom(SecretClient client) { + return secretName -> client.getSecret(secretName).getValue(); + } + + private static SecretClient buildClient() { + String vaultUrl = prop("azure.keyvault.url", ""); + String authMethod = prop("azure.auth.method", "default"); + + TokenCredential credential; + if ("client_secret".equalsIgnoreCase(authMethod)) { + credential = new ClientSecretCredentialBuilder() + .tenantId(prop("azure.tenant.id", "")) + .clientId(prop("azure.client.id", "")) + .clientSecret(prop("azure.client.secret", "")) + .build(); + } else { + credential = new DefaultAzureCredentialBuilder().build(); + } + + Debug.logInfo("AzureKeyVaultSecretsProvider: initialized vault=" + vaultUrl + + " auth=" + authMethod, MODULE); + + return new SecretClientBuilder() + .vaultUrl(vaultUrl) + .credential(credential) + .buildClient(); + } + + private static String prop(String key, String defaultValue) { + return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue); + } +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java b/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java new file mode 100644 index 000000000..622b7ae71 --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java @@ -0,0 +1,57 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.bitwarden; + +import java.io.IOException; + +/** + * Thin seam over HTTP calls used by {@link BitwardenSecretsProvider}. + * Kept package-private so tests can substitute a mock without making real + * network calls. + */ +interface BitwardenHttpClient { + + /** + * Performs an authenticated GET request. + * + * @param url the full request URL + * @param bearerToken the OAuth bearer token + * @return the response body as a UTF-8 string + * @throws IOException if the request fails or the server returns a non-2xx status + */ + String get(String url, String bearerToken) throws IOException; + + /** + * Performs a form-encoded POST request (no auth header — used for token exchange). + * + * @param url the full request URL + * @param formBody {@code application/x-www-form-urlencoded} encoded body + * @return the response body as a UTF-8 string + * @throws IOException if the request fails or the server returns a non-2xx status + */ + String post(String url, String formBody) throws IOException; + + /** + * Releases any resources held by the underlying HTTP client (e.g. connection pools). + * Implementations backed by {@link java.net.http.HttpClient} should close it here; + * {@link java.net.http.HttpClient} became {@link AutoCloseable} in Java 21. + * The default no-op is safe for mock implementations used in tests. + */ + default void close() { } +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java new file mode 100644 index 000000000..9be3b607d --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java @@ -0,0 +1,596 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.bitwarden; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.Map; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.ofbiz.base.crypto.ConfigCryptoUtil; +import org.apache.ofbiz.base.lang.ThreadSafe; +import org.apache.ofbiz.base.secret.SecretProvider; +import org.apache.ofbiz.base.secret.SecretProviderUtil; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilProperties; + +/** + * {@link SecretProvider} implementation backed by Bitwarden Secrets Manager. + * + *

Authentication

+ *

Uses a machine account access token (created in the Bitwarden SM console). + * The token has the format {@code 0..:}. + * The provider parses the token to extract OAuth credentials for the identity service + * and a 16-byte sealing key used to derive the org symmetric key.

+ * + *

Key derivation

+ *

The 16-byte sealing key from the token is expanded to a 64-byte derived key via: + *

    + *
  1. {@code PRK = HMAC-SHA256(key="bitwarden-accesstoken", msg=sealingKey)}
  2. + *
  3. {@code key64 = HKDF-Expand(PRK, info="sm-access-token", length=64)}
  4. + *
+ * This derived key decrypts the {@code encrypted_payload} field returned by the OAuth endpoint. + * The payload contains a JSON object {@code {"encryptionKey": ""}} whose value is the + * 64-byte org symmetric key used for all subsequent secret decryption. + * + *

End-to-end encryption

+ *

Secret keys (names) and values are returned from the API in encrypted form. + * This provider decrypts them using AES-256-CBC with HMAC-SHA256 integrity verification.

+ * + *

Per-key naming overrides

+ *

Secrets are looked up by matching the OFBiz key (with prefix) against the secret's + * title in Bitwarden. If a specific key needs a different title, set + * {@code key.alias.=}. The alias is checked first; keys with no + * alias entry use the logical key unchanged.

+ * + *

Configure via {@code plugins/bitwarden-secrets-provider/config/bitwarden-secrets.properties}.

+ */ +@ThreadSafe +public final class BitwardenSecretsProvider implements SecretProvider { + + private static final String MODULE = BitwardenSecretsProvider.class.getName(); + private static final String CONFIG_RESOURCE = "bitwarden-secrets"; + private static final ObjectMapper JSON = new ObjectMapper(); + + private final BitwardenHttpClient httpClient; + private final String apiUrl; + private final String identityUrl; + private final String organizationId; + private final String secretNamePrefix; + private final long cacheTtlMs; + private final Map keyAliases; + + // Parsed from the access token — never stored in config + private final String oauthClientId; + private final String oauthClientSecret; + // 16-byte sealing key from token; used to derive the org enc/mac keys after first OAuth call + private final byte[] sealingKey; + // Org symmetric key halves — null until first successful OAuth + payload decryption + private volatile byte[] encKey; + private volatile byte[] macKey; + + // Cached OAuth bearer token + its expiry + private volatile String bearerToken = null; + private volatile long bearerTokenExpiresAt = 0L; + + private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>(); + + /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */ + public BitwardenSecretsProvider() throws GeneralException { + this(buildHttpClient(), + prop("bitwarden.api.url", "https://api.bitwarden.com").replaceAll("/+$", ""), + prop("bitwarden.identity.url", "https://identity.bitwarden.com").replaceAll("/+$", ""), + prop("bitwarden.organization.id", ""), + prop("bitwarden.secret.name.prefix", ""), + SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "bitwarden.cache.ttl.seconds", 3600, MODULE), + prop("bitwarden.access.token", ""), + SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE)); + } + + /** + * Package-private constructor used by unit tests to inject mock HTTP client and + * pre-parsed key material. + */ + BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl, String identityUrl, + String organizationId, String secretNamePrefix, long cacheTtlMs, + String clientId, String clientSecret, byte[] encKey, byte[] macKey) { + this(httpClient, apiUrl, identityUrl, organizationId, secretNamePrefix, cacheTtlMs, + clientId, clientSecret, encKey, macKey, Map.of()); + } + + /** + * Package-private constructor used by unit tests to inject mock HTTP client, pre-parsed + * key material, and key aliases. + */ + BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl, String identityUrl, + String organizationId, String secretNamePrefix, long cacheTtlMs, + String clientId, String clientSecret, byte[] encKey, byte[] macKey, + Map keyAliases) { + this.httpClient = httpClient; + this.apiUrl = apiUrl; + this.identityUrl = identityUrl; + this.organizationId = organizationId; + this.secretNamePrefix = secretNamePrefix; + this.cacheTtlMs = cacheTtlMs; + this.oauthClientId = clientId; + this.oauthClientSecret = clientSecret; + this.sealingKey = null; // not needed when enc/mac keys are injected directly + this.encKey = encKey; + this.macKey = macKey; + this.keyAliases = keyAliases; + } + + /** Full constructor called by the public no-arg constructor after parsing the token. */ + private BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl, String identityUrl, + String organizationId, String secretNamePrefix, long cacheTtlMs, + String rawAccessToken, Map keyAliases) throws GeneralException { + this.httpClient = httpClient; + this.apiUrl = apiUrl; + this.identityUrl = identityUrl; + this.organizationId = organizationId; + this.secretNamePrefix = secretNamePrefix; + this.cacheTtlMs = cacheTtlMs; + this.keyAliases = keyAliases; + + // Parse: "0..:" + int colonIdx = rawAccessToken.lastIndexOf(':'); + if (colonIdx < 0) { + throw new GeneralException( + "Invalid Bitwarden access token format — missing ':' separator"); + } + String identityPart = rawAccessToken.substring(0, colonIdx); + String sealingKeyBase64 = rawAccessToken.substring(colonIdx + 1); + + String[] dotParts = identityPart.split("\\."); + if (dotParts.length != 3 || !"0".equals(dotParts[0])) { + throw new GeneralException( + "Invalid Bitwarden access token format — expected '0..:'"); + } + String serviceAccountId = dotParts[1]; + String clientSecretPart = dotParts[2]; + + // client_id is the service account UUID directly (not "service-account.") + this.oauthClientId = serviceAccountId; + this.oauthClientSecret = clientSecretPart; + + byte[] keyBytes; + try { + keyBytes = Base64.getDecoder().decode(sealingKeyBase64); + } catch (IllegalArgumentException e) { + throw new GeneralException("Invalid Bitwarden access token — sealing key is not valid base64", e); + } + if (keyBytes.length != 16) { + throw new GeneralException("Invalid Bitwarden access token — sealing key must be 16 bytes, got " + + keyBytes.length); + } + this.sealingKey = keyBytes; + // encKey and macKey are null until derived from the OAuth encrypted_payload + this.encKey = null; + this.macKey = null; + + Debug.logInfo("BitwardenSecretsProvider: initialized api=" + apiUrl + + " org=" + organizationId, MODULE); + } + + @Override + public String getSecret(String key) throws GeneralException { + String cached = cache.get(key); + if (cached != null) { + return cached; + } + + String physicalKey = keyAliases.getOrDefault(key, key); + String secretName = secretNamePrefix + physicalKey; + String value = fetchFromBitwarden(secretName); + value = ConfigCryptoUtil.decryptIfEncrypted(value, key); + + cache.put(key, value, cacheTtlMs); + return value; + } + + /** + * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call + * to re-fetch and re-decrypt from Bitwarden SM. + */ + @Override + public void invalidateCache() { + cache.clear(); + Debug.logInfo("BitwardenSecretsProvider: secret cache invalidated", MODULE); + } + + @Override + public boolean isFallbackEnabled() { + return Boolean.parseBoolean(prop("bitwarden.fallback.enabled", "true")); + } + + @Override + public void close() { + httpClient.close(); + } + + // -- private helpers -- + + private String fetchFromBitwarden(String secretName) throws GeneralException { + String bearer = ensureBearerToken(); + String secretId = findSecretId(secretName, bearer); + return fetchAndDecryptValue(secretId, secretName, bearer); + } + + /** Returns a valid OAuth bearer token, refreshing it if expired. */ + private synchronized String ensureBearerToken() throws GeneralException { + if (bearerToken != null && System.currentTimeMillis() < bearerTokenExpiresAt) { + return bearerToken; + } + + String body = "grant_type=client_credentials" + + "&scope=api.secrets" + + "&client_id=" + oauthClientId + + "&client_secret=" + oauthClientSecret; + + String responseJson; + try { + responseJson = httpClient.post(identityUrl + "/connect/token", body); + } catch (IOException e) { + throw new GeneralException("Bitwarden identity token request failed: " + e.getMessage(), e); + } + + try { + JsonNode root = JSON.readTree(responseJson); + JsonNode tokenNode = root.get("access_token"); + JsonNode expiresNode = root.get("expires_in"); + if (tokenNode == null || tokenNode.isNull()) { + throw new GeneralException("Bitwarden identity response missing 'access_token'"); + } + bearerToken = tokenNode.asText(); + long expiresIn = expiresNode != null ? expiresNode.asLong(3600L) : 3600L; + // Subtract 60 s to renew slightly before expiry + bearerTokenExpiresAt = System.currentTimeMillis() + (expiresIn - 60L) * 1000L; + + // Derive and store the org enc/mac keys from the encrypted_payload (first call only) + if (encKey == null) { + JsonNode payloadNode = root.get("encrypted_payload"); + if (payloadNode != null && !payloadNode.isNull()) { + deriveAndStoreOrgKey(payloadNode.asText()); + } + } + + return bearerToken; + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + throw new GeneralException("Failed to parse Bitwarden identity response: " + e.getMessage(), e); + } + } + + /** + * Derives the org symmetric key from the sealing key and the OAuth encrypted_payload, + * then stores the result in {@link #encKey} and {@link #macKey}. + * + *

Algorithm (matches Bitwarden SDK {@code derive_shareable_key}):

+ *
    + *
  1. {@code PRK = HMAC-SHA256(key="bitwarden-accesstoken", msg=sealingKey)}
  2. + *
  3. {@code key64 = HKDF-Expand(PRK, info="sm-access-token", length=64)}
  4. + *
  5. Decrypt {@code encrypted_payload} (type-2 cipher) with key64.
  6. + *
  7. Parse JSON {@code {"encryptionKey":""}} → 64-byte org key.
  8. + *
+ */ + private void deriveAndStoreOrgKey(String encryptedPayload) throws GeneralException { + // Step 1 & 2: derive 64-byte key from the 16-byte sealing key + byte[] prk = hmacSha256("bitwarden-accesstoken".getBytes(StandardCharsets.UTF_8), sealingKey); + byte[] key64 = hkdfExpand(prk, "sm-access-token".getBytes(StandardCharsets.UTF_8), 64); + byte[] derivedEnc = Arrays.copyOfRange(key64, 0, 32); + byte[] derivedMac = Arrays.copyOfRange(key64, 32, 64); + + // Step 3: decrypt the encrypted_payload to get the org key JSON + byte[] payloadBytes = decryptCipherBytes(encryptedPayload, derivedEnc, derivedMac); + + // Step 4: parse {"encryptionKey": ""} and extract the 64-byte org key + try { + JsonNode root = JSON.readTree(payloadBytes); + JsonNode encKeyNode = root.get("encryptionKey"); + if (encKeyNode == null || encKeyNode.isNull()) { + throw new GeneralException("Bitwarden encrypted_payload missing 'encryptionKey' field"); + } + byte[] orgKey = Base64.getDecoder().decode(encKeyNode.asText()); + if (orgKey.length != 64) { + throw new GeneralException( + "Bitwarden org key must be 64 bytes, got " + orgKey.length); + } + this.encKey = Arrays.copyOfRange(orgKey, 0, 32); + this.macKey = Arrays.copyOfRange(orgKey, 32, 64); + Debug.logInfo("BitwardenSecretsProvider: org encryption key derived successfully", MODULE); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + throw new GeneralException( + "Failed to parse Bitwarden encrypted_payload JSON: " + e.getMessage(), e); + } + } + + /** Lists the organization's secrets and returns the ID of the one matching secretName. */ + private String findSecretId(String secretName, String bearer) throws GeneralException { + String listUrl = apiUrl + "/organizations/" + organizationId + "/secrets"; + String responseJson; + try { + responseJson = httpClient.get(listUrl, bearer); + } catch (IOException e) { + throw new GeneralException("Bitwarden secrets list request failed: " + e.getMessage(), e); + } + + try { + JsonNode root = JSON.readTree(responseJson); + JsonNode data = root.get("secrets"); + if (data == null || !data.isArray()) { + throw new GeneralException("Bitwarden secrets list response missing 'secrets' array"); + } + + for (JsonNode item : data) { + JsonNode encKey = item.get("key"); + if (encKey == null) continue; + + String decryptedKey = decrypt(encKey.asText()); + if (secretName.equals(decryptedKey)) { + JsonNode idNode = item.get("id"); + if (idNode == null || idNode.isNull()) { + throw new GeneralException("Bitwarden secret '" + secretName + "' has no id field"); + } + return idNode.asText(); + } + } + throw new GeneralException("No Bitwarden secret found with key '" + secretName + "'"); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + throw new GeneralException("Failed to parse Bitwarden secrets list: " + e.getMessage(), e); + } + } + + /** Fetches the secret by ID and decrypts its value. */ + private String fetchAndDecryptValue(String secretId, String secretName, String bearer) + throws GeneralException { + String secretUrl = apiUrl + "/secrets/" + secretId; + String responseJson; + try { + responseJson = httpClient.get(secretUrl, bearer); + } catch (IOException e) { + throw new GeneralException("Bitwarden secret fetch failed for '" + secretName + + "': " + e.getMessage(), e); + } + + try { + JsonNode root = JSON.readTree(responseJson); + JsonNode encValueNode = root.get("value"); + if (encValueNode == null || encValueNode.isNull()) { + throw new GeneralException("Bitwarden secret '" + secretName + "' has no value field"); + } + String plaintext = decrypt(encValueNode.asText()); + if (plaintext.isEmpty()) { + throw new GeneralException("Bitwarden secret '" + secretName + "' decrypted to empty string"); + } + return plaintext; + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + throw new GeneralException("Failed to parse Bitwarden secret response: " + e.getMessage(), e); + } + } + + /** + * Decrypts a Bitwarden type-2 cipher string using the instance's org enc/mac keys. + * + *

Format: {@code 2.||}

+ */ + String decrypt(String cipherString) throws GeneralException { + try { + return new String(decryptCipherBytes(cipherString, encKey, macKey), StandardCharsets.UTF_8); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + throw new GeneralException("Bitwarden decryption failed: " + e.getMessage(), e); + } + } + + /** + * Core AES-256-CBC + HMAC-SHA256 decryption for a Bitwarden type-2 cipher string. + * Accepts explicit enc/mac keys so it can be used both for the org key derivation + * (with the derived keys) and for secret decryption (with the org keys). + */ + private static byte[] decryptCipherBytes(String cipherString, byte[] encKey, byte[] macKey) + throws GeneralException { + if (!cipherString.startsWith("2.")) { + throw new GeneralException( + "Unsupported Bitwarden cipher type — expected type 2 (AES-CBC-256-HMAC-SHA256)"); + } + String[] parts = cipherString.substring(2).split("\\|"); + if (parts.length != 3) { + throw new GeneralException( + "Invalid Bitwarden cipher string — expected '2.||'"); + } + + byte[] iv; + byte[] ciphertext; + byte[] expectedHmac; + try { + iv = Base64.getDecoder().decode(parts[0]); + ciphertext = Base64.getDecoder().decode(parts[1]); + expectedHmac = Base64.getDecoder().decode(parts[2]); + } catch (IllegalArgumentException e) { + throw new GeneralException("Invalid base64 in Bitwarden cipher string", e); + } + + try { + // Verify integrity: HMAC-SHA256(iv || ciphertext, macKey) + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(macKey, "HmacSHA256")); + mac.update(iv); + byte[] computedHmac = mac.doFinal(ciphertext); + if (!MessageDigest.isEqual(computedHmac, expectedHmac)) { + throw new GeneralException( + "Bitwarden HMAC verification failed — wrong key or tampered ciphertext"); + } + + // Decrypt: AES-256-CBC + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, + new SecretKeySpec(encKey, "AES"), + new IvParameterSpec(iv)); + return cipher.doFinal(ciphertext); + + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + throw new GeneralException("Bitwarden decryption failed: " + e.getMessage(), e); + } + } + + /** HMAC-SHA256(key, data). */ + private static byte[] hmacSha256(byte[] key, byte[] data) throws GeneralException { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } catch (Exception e) { + throw new GeneralException("HMAC-SHA256 failed: " + e.getMessage(), e); + } + } + + /** + * HKDF-Expand (RFC 5869) using HMAC-SHA256. + * Produces {@code length} bytes of output keying material from {@code prk} and {@code info}. + */ + private static byte[] hkdfExpand(byte[] prk, byte[] info, int length) throws GeneralException { + try { + byte[] result = new byte[length]; + byte[] prev = new byte[0]; + int offset = 0; + int counter = 1; + + while (offset < length) { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(prk, "HmacSHA256")); + mac.update(prev); + mac.update(info); + mac.update((byte) counter++); + prev = mac.doFinal(); + + int toCopy = Math.min(prev.length, length - offset); + System.arraycopy(prev, 0, result, offset, toCopy); + offset += toCopy; + } + return result; + } catch (Exception e) { + throw new GeneralException("HKDF-Expand failed: " + e.getMessage(), e); + } + } + + private static BitwardenHttpClient buildHttpClient() { + int connectTimeout = parseSeconds(prop("bitwarden.connect.timeout.seconds", "5")); + int readTimeout = parseSeconds(prop("bitwarden.read.timeout.seconds", "10")); + + HttpClient javaClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(connectTimeout)) + .build(); + + return new BitwardenHttpClient() { + @Override + public void close() { + // HttpClient became AutoCloseable in Java 21; safe to ignore on earlier versions. + if (javaClient instanceof AutoCloseable) { + AutoCloseable closeable = (AutoCloseable) javaClient; + try { + closeable.close(); + } catch (Exception e) { + // Nothing meaningful to do during shutdown. + } + } + } + + @Override + public String get(String url, String bearerToken) throws IOException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + bearerToken) + .header("Accept", "application/json") + .timeout(Duration.ofSeconds(readTimeout)) + .GET() + .build(); + return send(request); + } + + @Override + public String post(String url, String formBody) throws IOException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .timeout(Duration.ofSeconds(readTimeout)) + .POST(HttpRequest.BodyPublishers.ofString(formBody, StandardCharsets.UTF_8)) + .build(); + return send(request); + } + + private String send(HttpRequest request) throws IOException { + HttpResponse response; + try { + response = javaClient.send(request, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("HTTP request interrupted", e); + } + int status = response.statusCode(); + if (status < 200 || status >= 300) { + throw new IOException("Bitwarden API returned HTTP " + status + + " for " + request.uri()); + } + return response.body(); + } + }; + } + + private static int parseSeconds(String raw) { + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException e) { + return 10; + } + } + + + private static String prop(String key, String defaultValue) { + return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue); + } + +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java b/secretshub/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java new file mode 100644 index 000000000..4cd00633f --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java @@ -0,0 +1,35 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.envvar; + +/** + * Thin seam over environment variable reads used by {@link EnvVarSecretProvider}. + * Kept package-private so tests can substitute a fake map without depending on + * real process environment variables (which a running JVM cannot set for itself). + */ +@FunctionalInterface +interface EnvVarReader { + /** + * Retrieves the current value of the named environment variable. + * + * @param name the environment variable name + * @return the value, or {@code null} if not set + */ + String getenv(String name); +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java b/secretshub/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java new file mode 100644 index 000000000..bd54300ac --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java @@ -0,0 +1,120 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.envvar; + +import java.util.Locale; +import java.util.Map; + +import org.apache.ofbiz.base.crypto.ConfigCryptoUtil; +import org.apache.ofbiz.base.lang.ThreadSafe; +import org.apache.ofbiz.base.secret.SecretProvider; +import org.apache.ofbiz.base.secret.SecretProviderUtil; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilProperties; +import org.apache.ofbiz.base.util.UtilValidate; + +/** + * {@link SecretProvider} implementation that resolves secrets from this + * process's own environment variables, instead of calling out to a remote + * vault SDK/API the way the other six provider plugins do. + * + *

This is the generic counterpart to the file-mount mechanism the CSI + * Secrets Store Driver provides: whatever external tool already placed a + * value into this JVM's environment before OFBiz started — a Kubernetes + * External Secrets Operator sync into {@code envFrom}, a Doppler/Infisical + * {@code run} wrapper, a plain systemd {@code EnvironmentFile} — this + * provider can resolve, with no knowledge of which tool did the placing.

+ * + *

Name transform

+ *

Environment variable names are restricted to shell-identifier + * characters, unlike OFBiz secret keys (e.g. {@code jdbc-password.mysql-ofbiz}), + * so the key is deterministically transformed: uppercased, every character + * that isn't {@code A-Z} or {@code 0-9} replaced with {@code _}, then + * prefixed with {@code envvar.name.prefix} (default {@code OFBIZ_}):

+ *
+ *   jdbc-password.mysql-ofbiz  ->  OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ
+ * 
+ *

This transform is fixed by default — see {@code config/env-var-secrets.properties} + * — so any external tool can predict the expected env var name without reading OFBiz + * source. A deployment that needs a specific key to resolve to a different env var name + * (e.g. a name collision with something else already in its environment) may set + * {@code key.alias.=} as an explicit per-key override; this is + * opt-in and does not change the default transform for every other key.

+ * + *

No caching is implemented: unlike a remote API call, reading this + * process's own environment is already an in-memory lookup, and a JVM's + * environment variables are immutable for its lifetime, so there is + * nothing to gain from caching the result.

+ */ +@ThreadSafe +public final class EnvVarSecretProvider implements SecretProvider { + + private static final String MODULE = EnvVarSecretProvider.class.getName(); + private static final String CONFIG_RESOURCE = "env-var-secrets"; + + private final EnvVarReader reader; + private final String namePrefix; + private final Map keyAliases; + + /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */ + public EnvVarSecretProvider() { + this(System::getenv, prop("envvar.name.prefix", "OFBIZ_"), SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE)); + } + + /** Package-private constructor used by unit tests to inject a fake environment. */ + EnvVarSecretProvider(EnvVarReader reader, String namePrefix) { + this(reader, namePrefix, Map.of()); + } + + /** Package-private constructor used by unit tests to inject a fake environment and key aliases. */ + EnvVarSecretProvider(EnvVarReader reader, String namePrefix, Map keyAliases) { + this.reader = reader; + this.namePrefix = namePrefix; + this.keyAliases = keyAliases; + } + + @Override + public String getSecret(String key) throws GeneralException { + String envName = keyAliases.getOrDefault(key, toEnvVarName(key)); + String value = reader.getenv(envName); + if (UtilValidate.isEmpty(value)) { + throw new GeneralException("Secret key '" + key + "' not found — expected environment " + + "variable '" + envName + "' to be set"); + } + return ConfigCryptoUtil.decryptIfEncrypted(value, key); + } + + @Override + public boolean isFallbackEnabled() { + return Boolean.parseBoolean(prop("envvar.fallback.enabled", "true")); + } + + /** + * Transforms an OFBiz secret key into the environment variable name this + * provider will look up. Package-private so it can be unit tested directly. + */ + String toEnvVarName(String key) { + return namePrefix + key.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "_"); + } + + private static String prop(String name, String defaultValue) { + return UtilProperties.getPropertyValue(CONFIG_RESOURCE, name, defaultValue); + } + +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java new file mode 100644 index 000000000..125ff5869 --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java @@ -0,0 +1,221 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.gcpsecretmanager; + +import java.io.FileInputStream; +import java.io.IOException; +import java.util.Map; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; +import com.google.cloud.secretmanager.v1.SecretManagerServiceSettings; + +import org.apache.ofbiz.base.crypto.ConfigCryptoUtil; + +import org.apache.ofbiz.base.lang.ThreadSafe; +import org.apache.ofbiz.base.secret.SecretProvider; +import org.apache.ofbiz.base.secret.SecretProviderUtil; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilProperties; + +/** + * {@link SecretProvider} implementation backed by Google Cloud Secret Manager. + * + *

Authentication

+ *

Two modes are supported via {@code gcp.credentials.file}:

+ *
    + *
  • Application Default Credentials (ADC) — leave the property empty. + * GCP automatically provides credentials when running on GKE, GCE, Cloud Run, or + * after {@code gcloud auth application-default login} locally.
  • + *
  • Service Account key file — set {@code gcp.credentials.file} to the + * path of a downloaded JSON key file. Suitable for on-premise deployments.
  • + *
+ * + *

Secret name mapping

+ *

GCP secret names may only contain letters, digits, hyphens and underscores. + * OFBiz keys contain dots (e.g. {@code jdbc-password.mysql-ofbiz}), which are invalid. + * Set {@code gcp.secret.name.dot.replacement=-} (the default) to replace dots with + * hyphens, so the key maps to {@code jdbc-password-mysql-ofbiz} in the vault.

+ * + *

Secret resource name

+ *

Built as: + * {@code projects/{projectId}/secrets/{prefix}{sanitizedKey}/versions/{version}}

+ * + *

Per-key naming overrides

+ *

If even the dot-replacement convention above doesn't produce an acceptable name for a + * specific key, set {@code key.alias.=} to store that one key + * under an explicit name instead. The alias is checked first; only keys with no alias entry + * fall through to the dot-replacement convention.

+ * + *

Configure via {@code plugins/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties}.

+ */ +@ThreadSafe +public final class GcpSecretManagerSecretsProvider implements SecretProvider { + + private static final String MODULE = GcpSecretManagerSecretsProvider.class.getName(); + private static final String CONFIG_RESOURCE = "gcp-secret-manager"; + + private final GcpSecretReader secretReader; + private final SecretManagerServiceClient gcpClient; // null when injected via test constructor + private final String projectId; + private final String secretNamePrefix; + private final String version; + private final String dotReplacement; + private final long cacheTtlMs; + private final Map keyAliases; + + private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>(); + + /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */ + public GcpSecretManagerSecretsProvider() throws GeneralException { + SecretManagerServiceClient client = buildClient(); + this.secretReader = readerFrom(client); + this.gcpClient = client; + this.projectId = prop("gcp.project.id", ""); + this.secretNamePrefix = prop("gcp.secret.name.prefix", ""); + this.version = prop("gcp.secret.version", "latest"); + this.dotReplacement = prop("gcp.secret.name.dot.replacement", "-"); + this.cacheTtlMs = SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "gcp.cache.ttl.seconds", 3600, MODULE); + this.keyAliases = SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE); + } + + /** Package-private constructor used by unit tests to inject a {@link GcpSecretReader} lambda. */ + GcpSecretManagerSecretsProvider(GcpSecretReader secretReader, String projectId, + String secretNamePrefix, String version, String dotReplacement, long cacheTtlMs) { + this(secretReader, projectId, secretNamePrefix, version, dotReplacement, cacheTtlMs, Map.of()); + } + + /** Package-private constructor used by unit tests to inject a reader and key aliases. */ + GcpSecretManagerSecretsProvider(GcpSecretReader secretReader, String projectId, + String secretNamePrefix, String version, String dotReplacement, long cacheTtlMs, + Map keyAliases) { + this.secretReader = secretReader; + this.gcpClient = null; + this.projectId = projectId; + this.secretNamePrefix = secretNamePrefix; + this.version = version; + this.dotReplacement = dotReplacement; + this.cacheTtlMs = cacheTtlMs; + this.keyAliases = keyAliases; + } + + @Override + public String getSecret(String key) throws GeneralException { + String cached = cache.get(key); + if (cached != null) { + return cached; + } + + String physicalKey = keyAliases.get(key); + String sanitizedKey = physicalKey != null ? physicalKey + : dotReplacement.isEmpty() ? key : key.replace(".", dotReplacement); + String secretName = secretNamePrefix + sanitizedKey; + String resourceName = "projects/" + projectId + "/secrets/" + secretName + "/versions/" + version; + + String value; + try { + value = secretReader.read(resourceName); + } catch (Exception e) { + throw new GeneralException( + "GCP Secret Manager read failed for '" + resourceName + "': " + e.getMessage(), e); + } + + if (value == null || value.isEmpty()) { + throw new GeneralException( + "GCP Secret Manager returned empty value for '" + resourceName + "'"); + } + + value = ConfigCryptoUtil.decryptIfEncrypted(value, key); + + cache.put(key, value, cacheTtlMs); + return value; + } + + /** + * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call + * to re-fetch from GCP. Useful after a secret rotation. + */ + @Override + public void invalidateCache() { + cache.clear(); + Debug.logInfo("GcpSecretManagerSecretsProvider: secret cache invalidated", MODULE); + } + + /** Closes the underlying {@link SecretManagerServiceClient} and releases its gRPC channel. */ + @Override + public void close() { + if (gcpClient != null) { + gcpClient.close(); + Debug.logInfo("GcpSecretManagerSecretsProvider: SecretManagerServiceClient closed", MODULE); + } + } + + @Override + public boolean isFallbackEnabled() { + return Boolean.parseBoolean(prop("gcp.fallback.enabled", "true")); + } + + // -- private helpers -- + + private static GcpSecretReader readerFrom(SecretManagerServiceClient client) { + return resourceName -> { + try { + return client.accessSecretVersion(resourceName) + .getPayload() + .getData() + .toStringUtf8(); + } catch (Exception e) { + throw new IOException(e.getMessage(), e); + } + }; + } + + private static SecretManagerServiceClient buildClient() throws GeneralException { + String credentialsFile = prop("gcp.credentials.file", ""); + try { + // HTTP/JSON transport avoids gRPC/Netty entirely — no --add-opens JVM flags needed on Java 17 + SecretManagerServiceSettings.Builder builder = SecretManagerServiceSettings.newHttpJsonBuilder(); + + if (!credentialsFile.isEmpty()) { + try (FileInputStream fis = new FileInputStream(credentialsFile)) { + GoogleCredentials credentials = GoogleCredentials + .fromStream(fis) + .createScoped("https://www.googleapis.com/auth/cloud-platform"); + builder.setCredentialsProvider(FixedCredentialsProvider.create(credentials)); + } + } + // else: Application Default Credentials are used automatically + + Debug.logInfo("GcpSecretManagerSecretsProvider: initialized project=" + + prop("gcp.project.id", "") + " version=" + prop("gcp.secret.version", "latest"), MODULE); + return SecretManagerServiceClient.create(builder.build()); + + } catch (IOException e) { + throw new GeneralException( + "GcpSecretManagerSecretsProvider: initialization failed: " + e.getMessage(), e); + } + } + + private static String prop(String key, String defaultValue) { + return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue); + } + +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java b/secretshub/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java new file mode 100644 index 000000000..411cfaa7f --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java @@ -0,0 +1,35 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.gcpsecretmanager; + +/** + * Thin seam over GCP Secret Manager reads used by {@link GcpSecretManagerSecretsProvider}. + * Kept package-private so tests can substitute a lambda without connecting to GCP. + */ +@FunctionalInterface +interface GcpSecretReader { + /** + * Accesses the secret version at the given fully-qualified resource name. + * + * @param resourceName e.g. {@code "projects/my-project/secrets/my-secret/versions/latest"} + * @return the plaintext secret value; never {@code null} + * @throws Exception if the access fails or the secret does not exist + */ + String read(String resourceName) throws Exception; +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java b/secretshub/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java new file mode 100644 index 000000000..0818488f5 --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java @@ -0,0 +1,39 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.hashicorpvault; + +import java.util.Map; + +import io.github.jopenlibs.vault.VaultException; + +/** + * Thin seam over Vault KV reads used by {@link HashicorpVaultSecretsProvider}. + * Kept package-private so tests can substitute a lambda without launching a real Vault server. + */ +@FunctionalInterface +interface HashicorpVaultReader { + /** + * Reads the KV secret at the given path. + * + * @param path the full KV path (e.g. {@code "secret/myapp/jdbc-password"}) + * @return the data fields; never {@code null} + * @throws VaultException if the read fails or the path does not exist + */ + Map read(String path) throws VaultException; +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java new file mode 100644 index 000000000..5e2c398e7 --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java @@ -0,0 +1,233 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.hashicorpvault; + +import java.util.Collections; +import java.util.Map; + +import io.github.jopenlibs.vault.SslConfig; +import io.github.jopenlibs.vault.Vault; +import io.github.jopenlibs.vault.VaultConfig; +import io.github.jopenlibs.vault.VaultException; + +import org.apache.ofbiz.base.crypto.ConfigCryptoUtil; + +import org.apache.ofbiz.base.lang.ThreadSafe; +import org.apache.ofbiz.base.secret.SecretProvider; +import org.apache.ofbiz.base.secret.SecretProviderUtil; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilProperties; + +/** + * {@link SecretProvider} implementation backed by HashiCorp Vault. + * + *

Supports both Token and AppRole authentication methods. The preferred + * production approach is AppRole — the role_id is non-sensitive and can live + * in config, while the secret_id is injected at deploy time via an environment + * variable or a secrets bootstrap mechanism.

+ * + *

Both KV v1 and KV v2 engines are supported via the {@code hashicorp.vault.kv.version} + * property. The driver handles the {@code /data/} path rewrite for KV v2 + * automatically when {@code engineVersion(2)} is configured.

+ * + *

Resolved secret values are cached in memory for the TTL configured by + * {@code hashicorp.vault.cache.ttl.seconds} (default 1 hour).

+ * + *

Per-key naming overrides

+ *

Vault KV paths generally accept the dot-separated OFBiz key verbatim, but if a specific + * mount or policy requires a different path segment, set + * {@code key.alias.=} to override that one key. The alias is + * checked first; keys with no alias entry use the logical key unchanged.

+ * + *

Configure via {@code plugins/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties}.

+ */ +@ThreadSafe +public final class HashicorpVaultSecretsProvider implements SecretProvider { + + private static final String MODULE = HashicorpVaultSecretsProvider.class.getName(); + private static final String CONFIG_RESOURCE = "hashicorp-vault-secrets"; + + private final HashicorpVaultReader vaultReader; + private final String kvMount; + private final String secretNamePrefix; + private final String field; + private final long cacheTtlMs; + private final Map keyAliases; + + private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>(); + + /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */ + public HashicorpVaultSecretsProvider() { + this(readerFrom(buildVault()), + prop("hashicorp.vault.kv.mount", "secret"), + prop("hashicorp.vault.secret.name.prefix", ""), + prop("hashicorp.vault.field", "password"), + SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "hashicorp.vault.cache.ttl.seconds", 3600, MODULE), + SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE)); + } + + /** Package-private constructor used by unit tests to inject a {@link HashicorpVaultReader} lambda. */ + HashicorpVaultSecretsProvider(HashicorpVaultReader vaultReader, String kvMount, String secretNamePrefix, + String field, long cacheTtlMs) { + this(vaultReader, kvMount, secretNamePrefix, field, cacheTtlMs, Map.of()); + } + + /** Package-private constructor used by unit tests to inject a vault reader and key aliases. */ + HashicorpVaultSecretsProvider(HashicorpVaultReader vaultReader, String kvMount, String secretNamePrefix, + String field, long cacheTtlMs, Map keyAliases) { + this.vaultReader = vaultReader; + this.kvMount = kvMount; + this.secretNamePrefix = secretNamePrefix; + this.field = field; + this.cacheTtlMs = cacheTtlMs; + this.keyAliases = keyAliases; + } + + @Override + public String getSecret(String key) throws GeneralException { + String cached = cache.get(key); + if (cached != null) { + return cached; + } + + String physicalKey = keyAliases.getOrDefault(key, key); + String path = kvMount + "/" + secretNamePrefix + physicalKey; + String value = readFromVault(path); + value = ConfigCryptoUtil.decryptIfEncrypted(value, key); + + cache.put(key, value, cacheTtlMs); + return value; + } + + /** + * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call + * for each key to re-fetch from Vault. Useful after a secret rotation. + */ + @Override + public void invalidateCache() { + cache.clear(); + Debug.logInfo("HashicorpVaultSecretsProvider: secret cache invalidated", MODULE); + } + + @Override + public boolean isFallbackEnabled() { + return Boolean.parseBoolean(prop("hashicorp.vault.fallback.enabled", "true")); + } + + @Override + public void close() { + // The Vault Java driver does not expose a close() method on the Vault client; + // connection resources are managed internally by the library. + } + + // -- private helpers -- + + private String readFromVault(String path) throws GeneralException { + Map data; + try { + data = vaultReader.read(path); + } catch (VaultException e) { + throw new GeneralException("Vault error reading path '" + path + "': " + e.getMessage(), e); + } + + if (data == null || data.isEmpty()) { + throw new GeneralException("No data found at Vault path '" + path + "' — check the path and auth policy"); + } + + if (!field.isEmpty()) { + String value = data.get(field); + if (value == null) { + throw new GeneralException( + "Field '" + field + "' not found in Vault secret at '" + path + "'"); + } + return value; + } + + if (data.size() == 1) { + return data.values().iterator().next(); + } + + throw new GeneralException("Secret at '" + path + "' has " + data.size() + + " fields — set hashicorp.vault.field to select one"); + } + + private static HashicorpVaultReader readerFrom(Vault vault) { + return path -> { + Map data = vault.logical().read(path).getData(); + return data != null ? data : Collections.emptyMap(); + }; + } + + private static Vault buildVault() { + String address = prop("hashicorp.vault.address", "http://127.0.0.1:8200"); + String authMethod = prop("hashicorp.vault.auth.method", "token"); + int kvVersion = parseKvVersion(prop("hashicorp.vault.kv.version", "2")); + boolean sslVerify = Boolean.parseBoolean(prop("hashicorp.vault.ssl.verify", "true")); + + try { + SslConfig ssl = new SslConfig().verify(sslVerify).build(); + String token; + + if ("approle".equalsIgnoreCase(authMethod)) { + String roleId = prop("hashicorp.vault.approle.role_id", ""); + String secretId = prop("hashicorp.vault.approle.secret_id", ""); + VaultConfig bootConfig = new VaultConfig() + .address(address) + .engineVersion(kvVersion) + .sslConfig(ssl) + .build(); + token = new Vault(bootConfig) + .auth() + .loginByAppRole(roleId, secretId) + .getAuthClientToken(); + } else { + token = prop("hashicorp.vault.token", ""); + } + + VaultConfig config = new VaultConfig() + .address(address) + .token(token) + .engineVersion(kvVersion) + .sslConfig(ssl) + .build(); + + Debug.logInfo("HashicorpVaultSecretsProvider: initialized address=" + address + + " auth=" + authMethod + " kv-version=" + kvVersion, MODULE); + return new Vault(config); + + } catch (VaultException e) { + throw new RuntimeException("HashicorpVaultSecretsProvider: initialization failed: " + e.getMessage(), e); + } + } + + private static int parseKvVersion(String raw) { + try { + int v = Integer.parseInt(raw.trim()); + if (v == 1 || v == 2) return v; + } catch (NumberFormatException ignored) { } + Debug.logWarning("Invalid hashicorp.vault.kv.version '" + raw + "', defaulting to 2", MODULE); + return 2; + } + + private static String prop(String key, String defaultValue) { + return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue); + } + +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java b/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java new file mode 100644 index 000000000..a6d9b4e62 --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java @@ -0,0 +1,42 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.onepassword; + +import java.io.IOException; + +/** + * Thin seam over HTTP GET calls used by {@link OnePasswordSecretsProvider}. + * Kept package-private so tests can substitute a mock without launching a real + * Connect Server. + */ +@FunctionalInterface +interface OnePasswordHttpClient { + /** + * Performs an authenticated GET request. + * + * @param url the full request URL + * @param bearerToken the Connect Server access token + * @return the response body as a UTF-8 string + * @throws IOException if the request fails or the server returns a non-2xx status + */ + String get(String url, String bearerToken) throws IOException; + + /** Releases any underlying HTTP connection pool resources. No-op by default. */ + default void close() { } +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java new file mode 100644 index 000000000..073b71352 --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java @@ -0,0 +1,295 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.onepassword; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.ofbiz.base.crypto.ConfigCryptoUtil; +import org.apache.ofbiz.base.lang.ThreadSafe; +import org.apache.ofbiz.base.secret.SecretProvider; +import org.apache.ofbiz.base.secret.SecretProviderUtil; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilProperties; + +/** + * {@link SecretProvider} implementation backed by a 1Password Connect Server. + * + *

Looks up items in a designated 1Password vault by matching the item title + * to the OFBiz secret key (with an optional prefix). The value of the configured + * field (default {@code "password"}) is returned as the secret.

+ * + *

Authentication uses a static Connect Server access token configured in + * {@code onepassword.connect.token}. Inject this token at deploy time — do not commit it.

+ * + *

API flow per lookup:

+ *
    + *
  1. {@code GET /v1/vaults/{vaultId}/items?filter=title eq "{title}"} — find the item UUID
  2. + *
  3. {@code GET /v1/vaults/{vaultId}/items/{itemId}} — fetch the full item with field values
  4. + *
  5. Scan {@code fields[]} for the entry whose {@code label} matches {@code onepassword.field}
  6. + *
+ * + *

Per-key naming overrides

+ *

Items are looked up by matching the OFBiz key (with prefix) against the item's + * title in 1Password. If a specific key needs a different title, set + * {@code key.alias.=}. The alias is checked first; keys with no + * alias entry use the logical key unchanged.

+ * + *

Configure via {@code plugins/onepassword-secrets-provider/config/onepassword.properties}.

+ */ +@ThreadSafe +public final class OnePasswordSecretsProvider implements SecretProvider { + + private static final String MODULE = OnePasswordSecretsProvider.class.getName(); + private static final String CONFIG_RESOURCE = "onepassword"; + private static final ObjectMapper JSON = new ObjectMapper(); + + private final OnePasswordHttpClient httpClient; + private final String connectUrl; + private final String token; + private final String vaultId; + private final String field; + private final String secretNamePrefix; + private final long cacheTtlMs; + private final Map keyAliases; + + private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>(); + + /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */ + public OnePasswordSecretsProvider() { + this(buildHttpClient(), + prop("onepassword.connect.url", "http://localhost:8080").replaceAll("/+$", ""), + prop("onepassword.connect.token", ""), + prop("onepassword.vault.id", ""), + prop("onepassword.field", "password"), + prop("onepassword.secret.name.prefix", ""), + SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "onepassword.cache.ttl.seconds", 3600, MODULE), + SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE)); + } + + /** Package-private constructor used by unit tests to inject a mock HTTP client. */ + OnePasswordSecretsProvider(OnePasswordHttpClient httpClient, String connectUrl, String token, + String vaultId, String field, String secretNamePrefix, long cacheTtlMs) { + this(httpClient, connectUrl, token, vaultId, field, secretNamePrefix, cacheTtlMs, Map.of()); + } + + /** Package-private constructor used by unit tests to inject a mock HTTP client and key aliases. */ + OnePasswordSecretsProvider(OnePasswordHttpClient httpClient, String connectUrl, String token, + String vaultId, String field, String secretNamePrefix, long cacheTtlMs, + Map keyAliases) { + this.httpClient = httpClient; + this.connectUrl = connectUrl; + this.token = token; + this.vaultId = vaultId; + this.field = field; + this.secretNamePrefix = secretNamePrefix; + this.cacheTtlMs = cacheTtlMs; + this.keyAliases = keyAliases; + } + + @Override + public String getSecret(String key) throws GeneralException { + String cached = cache.get(key); + if (cached != null) { + return cached; + } + + String title = secretNamePrefix + keyAliases.getOrDefault(key, key); + String value = fetchFromConnect(title); + value = ConfigCryptoUtil.decryptIfEncrypted(value, key); + + cache.put(key, value, cacheTtlMs); + return value; + } + + /** + * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call + * to re-fetch from the Connect Server. Useful after a secret update in 1Password. + */ + @Override + public void invalidateCache() { + cache.clear(); + Debug.logInfo("OnePasswordSecretsProvider: secret cache invalidated", MODULE); + } + + @Override + public boolean isFallbackEnabled() { + return Boolean.parseBoolean(prop("onepassword.fallback.enabled", "true")); + } + + @Override + public void close() { + httpClient.close(); + } + + // -- private helpers -- + + private String fetchFromConnect(String title) throws GeneralException { + String itemId = findItemId(title); + return fetchFieldValue(itemId, title); + } + + private String findItemId(String title) throws GeneralException { + String filter = "title eq \"" + title + "\""; + String encodedFilter; + try { + encodedFilter = URLEncoder.encode(filter, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new GeneralException("Failed to encode filter for title '" + title + "'", e); + } + + String searchUrl = connectUrl + "/v1/vaults/" + vaultId + "/items?filter=" + encodedFilter; + String responseBody; + try { + responseBody = httpClient.get(searchUrl, token); + } catch (IOException e) { + throw new GeneralException("1Password Connect request failed: " + e.getMessage(), e); + } + + try { + JsonNode items = JSON.readTree(responseBody); + if (!items.isArray() || items.size() == 0) { + throw new GeneralException("No 1Password item found with title '" + title + "'"); + } + JsonNode idNode = items.get(0).get("id"); + if (idNode == null || idNode.isNull()) { + throw new GeneralException("1Password item for '" + title + "' has no id field"); + } + return idNode.asText(); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + throw new GeneralException("Failed to parse 1Password item search response: " + e.getMessage(), e); + } + } + + private String fetchFieldValue(String itemId, String title) throws GeneralException { + String itemUrl = connectUrl + "/v1/vaults/" + vaultId + "/items/" + itemId; + String responseBody; + try { + responseBody = httpClient.get(itemUrl, token); + } catch (IOException e) { + throw new GeneralException("1Password Connect request failed for item '" + itemId + "': " + e.getMessage(), e); + } + + try { + JsonNode item = JSON.readTree(responseBody); + JsonNode fields = item.get("fields"); + if (fields == null || !fields.isArray()) { + throw new GeneralException("1Password item '" + title + "' has no fields array"); + } + + for (JsonNode f : fields) { + JsonNode labelNode = f.get("label"); + if (labelNode != null && field.equalsIgnoreCase(labelNode.asText())) { + JsonNode valueNode = f.get("value"); + if (valueNode == null || valueNode.isNull()) { + throw new GeneralException( + "Field '" + field + "' in 1Password item '" + title + "' has a null value"); + } + String value = valueNode.asText(); + if (value.isEmpty()) { + throw new GeneralException( + "Field '" + field + "' in 1Password item '" + title + "' is empty"); + } + return value; + } + } + throw new GeneralException( + "Field '" + field + "' not found in 1Password item '" + title + "'"); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + throw new GeneralException("Failed to parse 1Password item response: " + e.getMessage(), e); + } + } + + private static OnePasswordHttpClient buildHttpClient() { + int connectTimeout = parseSeconds(prop("onepassword.connect.timeout.seconds", "5")); + int readTimeout = parseSeconds(prop("onepassword.read.timeout.seconds", "10")); + + HttpClient javaClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(connectTimeout)) + .build(); + + Debug.logInfo("OnePasswordSecretsProvider: initialized connect-url=" + prop("onepassword.connect.url", ""), + MODULE); + + return new OnePasswordHttpClient() { + @Override + public String get(String url, String bearerToken) throws IOException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + bearerToken) + .header("Accept", "application/json") + .timeout(Duration.ofSeconds(readTimeout)) + .GET() + .build(); + + HttpResponse response; + try { + response = javaClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("HTTP request interrupted", e); + } + + int status = response.statusCode(); + if (status < 200 || status >= 300) { + throw new IOException("1Password Connect returned HTTP " + status + " for " + url); + } + return response.body(); + } + + @Override + public void close() { + if (javaClient instanceof AutoCloseable) { + AutoCloseable closeable = (AutoCloseable) javaClient; + try { + closeable.close(); + } catch (Exception ignored) { } + } + } + }; + } + + private static int parseSeconds(String raw) { + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException e) { + return 10; + } + } + + private static String prop(String key, String defaultValue) { + return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue); + } + +} diff --git a/secretshub/src/main/java/org/apache/ofbiz/secretshub/ActiveSecretProvider.java b/secretshub/src/main/java/org/apache/ofbiz/secretshub/ActiveSecretProvider.java new file mode 100644 index 000000000..0f6051efc --- /dev/null +++ b/secretshub/src/main/java/org/apache/ofbiz/secretshub/ActiveSecretProvider.java @@ -0,0 +1,111 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.secretshub; + +import java.util.Locale; +import java.util.Properties; + +import org.apache.ofbiz.awssecrets.AwsSecretsManagerProvider; +import org.apache.ofbiz.azurekeyvault.AzureKeyVaultSecretsProvider; +import org.apache.ofbiz.base.secret.SecretProvider; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilProperties; +import org.apache.ofbiz.bitwarden.BitwardenSecretsProvider; +import org.apache.ofbiz.envvar.EnvVarSecretProvider; +import org.apache.ofbiz.gcpsecretmanager.GcpSecretManagerSecretsProvider; +import org.apache.ofbiz.hashicorpvault.HashicorpVaultSecretsProvider; +import org.apache.ofbiz.onepassword.OnePasswordSecretsProvider; + +/** + * {@link SecretProvider} that delegates to exactly one of the bundled providers, selected via + * {@code secret.provider.active} in {@code secretshub.properties}. + * + *

This is the only class the {@code secretshub} component registers under + * {@code META-INF/services/org.apache.ofbiz.base.secret.SecretProvider}. Concatenating the 7 + * bundled providers' own service registrations would make {@link java.util.ServiceLoader} + * discover and construct all of them, eagerly building SDK clients for providers that were + * never configured. Routing everything through this single delegating class keeps the + * "only one provider active at a time" contract that used to be enforced by each plugin's own + * {@code ofbiz-component.xml enabled} flag.

+ */ +public final class ActiveSecretProvider implements SecretProvider { + + private static final String MODULE = ActiveSecretProvider.class.getName(); + + private static final String VALID_VALUES = "aws | azure | bitwarden | env-var | gcp | hashicorp-vault | onepassword"; + + private final SecretProvider delegate; + + /** Required no-arg constructor for {@link java.util.ServiceLoader}. */ + public ActiveSecretProvider() throws GeneralException { + Properties props = UtilProperties.getProperties("secretshub"); + String active = props == null ? null : props.getProperty("secret.provider.active"); + active = active == null ? "" : active.trim().toLowerCase(Locale.ROOT); + this.delegate = build(active); + Debug.logInfo("ActiveSecretProvider: secret.provider.active=" + active + + ", delegating to " + delegate.getClass().getName(), MODULE); + } + + /** Package-private for direct unit testing of the selection/fail-fast logic. */ + static SecretProvider build(String active) throws GeneralException { + switch (active) { + case "aws": + return new AwsSecretsManagerProvider(); + case "azure": + return new AzureKeyVaultSecretsProvider(); + case "bitwarden": + return new BitwardenSecretsProvider(); + case "env-var": + return new EnvVarSecretProvider(); + case "gcp": + return new GcpSecretManagerSecretsProvider(); + case "hashicorp-vault": + return new HashicorpVaultSecretsProvider(); + case "onepassword": + return new OnePasswordSecretsProvider(); + case "": + throw new IllegalStateException("secret.provider.active is blank in secretshub.properties. " + + "Set it to one of: " + VALID_VALUES); + default: + throw new IllegalStateException("Unrecognized secret.provider.active value '" + active + + "' in secretshub.properties. Valid values: " + VALID_VALUES); + } + } + + @Override + public String getSecret(String key) throws GeneralException { + return delegate.getSecret(key); + } + + @Override + public boolean isFallbackEnabled() { + return delegate.isFallbackEnabled(); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public void invalidateCache() { + delegate.invalidateCache(); + } +} diff --git a/secretshub/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/secretshub/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider new file mode 100644 index 000000000..361d1e7ca --- /dev/null +++ b/secretshub/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider @@ -0,0 +1 @@ +org.apache.ofbiz.secretshub.ActiveSecretProvider diff --git a/secretshub/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java new file mode 100644 index 000000000..0e88ab64f --- /dev/null +++ b/secretshub/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java @@ -0,0 +1,207 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.awssecrets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import org.apache.ofbiz.base.util.GeneralException; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest; +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse; +import software.amazon.awssdk.services.secretsmanager.model.ResourceNotFoundException; + +public class AwsSecretsManagerProviderTest { + + private static final long ONE_HOUR_MS = 3_600_000L; + + // -- Happy path -- + + @Test + public void getSecretReturnsSecretString() throws GeneralException { + SecretsManagerClient client = clientReturning("jdbc-password.mydb", "s3cr3t"); + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", ""); + + assertEquals("s3cr3t", provider.getSecret("jdbc-password.mydb")); + } + + @Test + public void getSecretCachesPreventsSecondAwsCall() throws GeneralException { + SecretsManagerClient client = clientReturning("jdbc-password.mydb", "s3cr3t"); + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", ""); + + provider.getSecret("jdbc-password.mydb"); + provider.getSecret("jdbc-password.mydb"); // second call — should hit cache + + verify(client, times(1)).getSecretValue(any(GetSecretValueRequest.class)); + } + + @Test + public void getSecretAppliesSecretNamePrefix() throws GeneralException { + SecretsManagerClient client = mock(SecretsManagerClient.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(GetSecretValueRequest.class); + when(client.getSecretValue(captor.capture())) + .thenReturn(response("val")); + + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "myapp/prod/", ""); + provider.getSecret("jdbc-password.ofbiz"); + + assertEquals("myapp/prod/jdbc-password.ofbiz", captor.getValue().secretId()); + } + + @Test + public void getSecretExtractsJsonFieldWhenConfigured() throws GeneralException { + String json = "{\"username\":\"dbuser\",\"password\":\"dbpass\"}"; + SecretsManagerClient client = clientReturning("jdbc-password.mydb", json); + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "password"); + + assertEquals("dbpass", provider.getSecret("jdbc-password.mydb")); + } + + @Test + public void getSecretUsesRawStringWhenNoJsonFieldConfigured() throws GeneralException { + String rawPassword = "plain-text-password"; + SecretsManagerClient client = clientReturning("jdbc-password.mydb", rawPassword); + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", ""); + + assertEquals("plain-text-password", provider.getSecret("jdbc-password.mydb")); + } + + // -- Cache invalidation -- + + @Test + public void invalidateCacheForcesRefetchOnNextCall() throws GeneralException { + SecretsManagerClient client = clientReturning("jdbc-password.mydb", "val"); + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", ""); + + provider.getSecret("jdbc-password.mydb"); + provider.invalidateCache(); + provider.getSecret("jdbc-password.mydb"); + + verify(client, times(2)).getSecretValue(any(GetSecretValueRequest.class)); + } + + @Test + public void getSecretExpiredCacheEntryTriggersRefetch() throws GeneralException { + SecretsManagerClient client = clientReturning("jdbc-password.mydb", "val"); + // TTL of -1 ms means the entry expires immediately (expiresAt is in the past) + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, -1L, "", ""); + + provider.getSecret("jdbc-password.mydb"); + provider.getSecret("jdbc-password.mydb"); // cache entry is expired — must re-fetch + + verify(client, times(2)).getSecretValue(any(GetSecretValueRequest.class)); + } + + // -- Key aliasing -- + + @Test + public void getSecretUsesAliasedNameWhenConfigured() throws GeneralException { + SecretsManagerClient client = mock(SecretsManagerClient.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(GetSecretValueRequest.class); + when(client.getSecretValue(captor.capture())).thenReturn(response("val")); + + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "", + Map.of("jdbc-password.mysql-ofbiz", "prod/ofbiz/mysql_db_password")); + provider.getSecret("jdbc-password.mysql-ofbiz"); + + assertEquals("prod/ofbiz/mysql_db_password", captor.getValue().secretId()); + } + + @Test + public void getSecretCachesByLogicalKeyNotAliasedName() throws GeneralException { + SecretsManagerClient client = mock(SecretsManagerClient.class); + when(client.getSecretValue(any(GetSecretValueRequest.class))).thenReturn(response("val")); + + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "", + Map.of("jdbc-password.mysql-ofbiz", "prod/ofbiz/mysql_db_password")); + provider.getSecret("jdbc-password.mysql-ofbiz"); + provider.getSecret("jdbc-password.mysql-ofbiz"); // second call — should hit cache + + verify(client, times(1)).getSecretValue(any(GetSecretValueRequest.class)); + } + + @Test + public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws GeneralException { + SecretsManagerClient client = mock(SecretsManagerClient.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(GetSecretValueRequest.class); + when(client.getSecretValue(captor.capture())).thenReturn(response("val")); + + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "", + Map.of("some.other.key", "some/other/alias")); + provider.getSecret("jdbc-password.mysql-ofbiz"); + + assertEquals("jdbc-password.mysql-ofbiz", captor.getValue().secretId()); + } + + // -- Error handling -- + + @Test + public void getSecretThrowsWhenSecretNotFound() { + SecretsManagerClient client = mock(SecretsManagerClient.class); + when(client.getSecretValue(any(GetSecretValueRequest.class))) + .thenThrow(ResourceNotFoundException.builder().message("not found").build()); + + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", ""); + assertThrows(GeneralException.class, () -> provider.getSecret("jdbc-password.missing")); + } + + @Test + public void getSecretThrowsWhenJsonFieldMissing() { + String json = "{\"username\":\"dbuser\"}"; // no "password" field + SecretsManagerClient client = clientReturning("jdbc-password.mydb", json); + + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "password"); + assertThrows(GeneralException.class, () -> provider.getSecret("jdbc-password.mydb")); + } + + @Test + public void getSecretThrowsWhenSecretStringIsNull() { + SecretsManagerClient client = mock(SecretsManagerClient.class); + // secretString() returns null — this is a binary secret + when(client.getSecretValue(any(GetSecretValueRequest.class))) + .thenReturn(GetSecretValueResponse.builder().build()); + + AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", ""); + assertThrows(GeneralException.class, () -> provider.getSecret("binary-secret")); + } + + // -- helpers -- + + private static SecretsManagerClient clientReturning(String secretId, String secretValue) { + SecretsManagerClient client = mock(SecretsManagerClient.class); + when(client.getSecretValue(any(GetSecretValueRequest.class))) + .thenReturn(response(secretValue)); + return client; + } + + private static GetSecretValueResponse response(String secretValue) { + return GetSecretValueResponse.builder().secretString(secretValue).build(); + } +} diff --git a/secretshub/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java new file mode 100644 index 000000000..01b0cf2da --- /dev/null +++ b/secretshub/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java @@ -0,0 +1,155 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.azurekeyvault; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.ofbiz.base.util.GeneralException; +import org.junit.jupiter.api.Test; + +public class AzureKeyVaultSecretsProviderTest { + + private static final long ONE_HOUR_MS = 3_600_000L; + + // -- Happy path -- + + @Test + public void getSecretReturnsSecretValue() throws GeneralException { + AzureKeyVaultReader reader = fixedReader("mykey", "s3cr3t"); + assertEquals("s3cr3t", provider(reader, "", "-").getSecret("mykey")); + } + + @Test + public void getSecretSanitizesDotInKey() throws GeneralException { + // OFBiz key "jdbc-password.mysql-ofbiz" → Azure name "jdbc-password-mysql-ofbiz" + AzureKeyVaultReader reader = fixedReader("jdbc-password-mysql-ofbiz", "dbpass"); + assertEquals("dbpass", provider(reader, "", "-").getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretAppliesPrefixAfterSanitizing() throws GeneralException { + AzureKeyVaultReader reader = fixedReader("prod-jdbc-password-mydb", "prodpass"); + assertEquals("prodpass", provider(reader, "prod-", "-").getSecret("jdbc-password.mydb")); + } + + @Test + public void getSecretCachePreventsDuplicateReaderCall() throws GeneralException { + AtomicInteger calls = new AtomicInteger(); + AzureKeyVaultReader reader = secretName -> { + calls.incrementAndGet(); + return "val"; + }; + AzureKeyVaultSecretsProvider p = provider(reader, "", "-"); + + p.getSecret("mykey"); + p.getSecret("mykey"); // cache hit + + assertEquals(1, calls.get()); + } + + @Test + public void invalidateCacheForcesRefetchOnNextCall() throws GeneralException { + AtomicInteger calls = new AtomicInteger(); + AzureKeyVaultReader reader = secretName -> { + calls.incrementAndGet(); + return "val"; + }; + AzureKeyVaultSecretsProvider p = provider(reader, "", "-"); + + p.getSecret("mykey"); + p.invalidateCache(); + p.getSecret("mykey"); + + assertEquals(2, calls.get()); + } + + @Test + public void getSecretExpiredCacheTriggersRefetch() throws GeneralException { + AtomicInteger calls = new AtomicInteger(); + AzureKeyVaultReader reader = secretName -> { + calls.incrementAndGet(); + return "val"; + }; + // TTL of -1 ms — entries expire immediately + AzureKeyVaultSecretsProvider p = new AzureKeyVaultSecretsProvider( + reader, "", "-", -1L); + + p.getSecret("mykey"); + p.getSecret("mykey"); + + assertEquals(2, calls.get()); + } + + @Test + public void getSecretDotReplacementDisabledKeepsDotsInName() throws GeneralException { + AzureKeyVaultReader reader = fixedReader("my.key", "val"); + assertEquals("val", provider(reader, "", "").getSecret("my.key")); + } + + // -- Key aliasing -- + + @Test + public void getSecretUsesAliasedNameInsteadOfDotReplacement() throws GeneralException { + AzureKeyVaultReader reader = fixedReader("prod-ofbiz-mysql-db-password", "dbpass"); + AzureKeyVaultSecretsProvider p = new AzureKeyVaultSecretsProvider(reader, "", "-", ONE_HOUR_MS, + Map.of("jdbc-password.mysql-ofbiz", "prod-ofbiz-mysql-db-password")); + + assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretFallsBackToDotReplacementWhenNoAliasConfigured() throws GeneralException { + AzureKeyVaultReader reader = fixedReader("jdbc-password-mysql-ofbiz", "dbpass"); + AzureKeyVaultSecretsProvider p = new AzureKeyVaultSecretsProvider(reader, "", "-", ONE_HOUR_MS, + Map.of("some.other.key", "some-other-alias")); + + assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz")); + } + + // -- Error handling -- + + @Test + public void getSecretThrowsOnReaderException() { + AzureKeyVaultReader reader = secretName -> { + throw new RuntimeException("SecretNotFound"); + }; + assertThrows(GeneralException.class, () -> provider(reader, "", "-").getSecret("missing")); + } + + @Test + public void getSecretThrowsOnEmptyValue() { + AzureKeyVaultReader reader = secretName -> ""; + assertThrows(GeneralException.class, () -> provider(reader, "", "-").getSecret("mykey")); + } + + // -- helpers -- + + private static AzureKeyVaultSecretsProvider provider(AzureKeyVaultReader reader, + String prefix, String dotReplacement) { + return new AzureKeyVaultSecretsProvider(reader, prefix, dotReplacement, ONE_HOUR_MS); + } + + private static AzureKeyVaultReader fixedReader(String expectedName, String value) { + return secretName -> expectedName.equals(secretName) ? value : null; + } +} diff --git a/secretshub/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java new file mode 100644 index 000000000..0dd2774f6 --- /dev/null +++ b/secretshub/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java @@ -0,0 +1,254 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.bitwarden; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.Map; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.ofbiz.base.util.GeneralException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link BitwardenSecretsProvider}. + * + *

A stable 64-byte test key is generated once for the test class. + * Helper methods produce properly-encrypted cipher strings so the decryption + * tests exercise the real AES-256-CBC + HMAC-SHA256 path, not just mocked output.

+ */ +public class BitwardenSecretsProviderTest { + + private static final long ONE_HOUR_MS = 3_600_000L; + private static final String API_URL = "https://api.bitwarden.com"; + private static final String IDENTITY_URL = "https://identity.bitwarden.com"; + private static final String ORG_ID = "org-abc"; + private static final String BEARER_TOKEN_RESPONSE = + "{\"access_token\":\"test-bearer\",\"expires_in\":3600}"; + + private static byte[] testEncKey; + private static byte[] testMacKey; + + @BeforeAll + public static void generateTestKey() { + byte[] keyBytes = new byte[64]; + new SecureRandom().nextBytes(keyBytes); + testEncKey = Arrays.copyOfRange(keyBytes, 0, 32); + testMacKey = Arrays.copyOfRange(keyBytes, 32, 64); + } + + // -- Decryption unit tests (no HTTP, pure crypto) -- + + @Test + public void decryptRoundTrip() throws Exception { + String plaintext = "my-secret-password"; + String cipherString = encrypt(plaintext); + + BitwardenSecretsProvider provider = provider(mockHttpForSecret("mykey", plaintext)); + assertEquals(plaintext, provider.decrypt(cipherString)); + } + + @Test + public void decryptThrowsOnWrongCipherType() throws Exception { + BitwardenSecretsProvider provider = provider(mock(BitwardenHttpClient.class)); + // type 1, not 2 + assertThrows(GeneralException.class, () -> provider.decrypt("1.abc|def|ghi")); + } + + @Test + public void decryptThrowsOnTamperedCiphertext() throws Exception { + String cipherString = encrypt("secret"); + // Flip a byte in the ciphertext part + String[] parts = cipherString.substring(2).split("\\|"); + byte[] ct = Base64.getDecoder().decode(parts[1]); + ct[0] ^= 0xFF; + String tampered = "2." + parts[0] + "|" + Base64.getEncoder().encodeToString(ct) + "|" + parts[2]; + + BitwardenSecretsProvider provider = provider(mock(BitwardenHttpClient.class)); + assertThrows(GeneralException.class, () -> provider.decrypt(tampered)); + } + + // -- Full flow tests (with mock HTTP) -- + + @Test + public void getSecretFetchesAndDecryptsSecret() throws Exception { + String secretValue = "db-password-123"; + BitwardenHttpClient client = mockHttpForSecret("jdbc-password.mydb", secretValue); + BitwardenSecretsProvider provider = provider(client); + + assertEquals(secretValue, provider.getSecret("jdbc-password.mydb")); + } + + @Test + public void getSecretAppliesSecretNamePrefix() throws Exception { + String secretValue = "dbpass"; + BitwardenHttpClient client = mockHttpForSecret("prod/jdbc-password.mydb", secretValue); + BitwardenSecretsProvider provider = providerWithPrefix(client, "prod/"); + + assertEquals(secretValue, provider.getSecret("jdbc-password.mydb")); + } + + @Test + public void getSecretCachePreventsSecondApiCall() throws Exception { + BitwardenHttpClient client = mockHttpForSecret("mykey", "val"); + BitwardenSecretsProvider provider = provider(client); + + provider.getSecret("mykey"); + provider.getSecret("mykey"); + + // Only 1 identity POST + 1 list GET + 1 secret GET = 3 calls for the first fetch + verify(client, times(1)).post(anyString(), anyString()); + verify(client, times(1)).get(contains("/organizations/"), anyString()); + verify(client, times(1)).get(contains("/secrets/"), anyString()); + } + + @Test + public void invalidateCacheForcesRefetch() throws Exception { + BitwardenHttpClient client = mockHttpForSecret("mykey", "val"); + BitwardenSecretsProvider provider = provider(client); + + provider.getSecret("mykey"); + provider.invalidateCache(); + provider.getSecret("mykey"); + + // Two full fetches — bearer token is still valid so POST called once + verify(client, times(2)).get(contains("/organizations/"), anyString()); + verify(client, times(2)).get(contains("/secrets/"), anyString()); + } + + // -- Key aliasing -- + + @Test + public void getSecretUsesAliasedTitle() throws Exception { + String secretValue = "dbpass"; + BitwardenHttpClient client = mockHttpForSecret("prod-ofbiz-mysql-db-password", secretValue); + BitwardenSecretsProvider provider = providerWithAliases(client, "", + Map.of("jdbc-password.mysql-ofbiz", "prod-ofbiz-mysql-db-password")); + + assertEquals(secretValue, provider.getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws Exception { + String secretValue = "dbpass"; + BitwardenHttpClient client = mockHttpForSecret("jdbc-password.mysql-ofbiz", secretValue); + BitwardenSecretsProvider provider = providerWithAliases(client, "", + Map.of("some.other.key", "some-other-alias")); + + assertEquals(secretValue, provider.getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretThrowsWhenSecretNotFound() throws Exception { + BitwardenHttpClient client = mock(BitwardenHttpClient.class); + when(client.post(anyString(), anyString())).thenReturn(BEARER_TOKEN_RESPONSE); + when(client.get(contains("/organizations/"), anyString())) + .thenReturn("{\"secrets\":[]}"); // empty list + + BitwardenSecretsProvider provider = provider(client); + assertThrows(GeneralException.class, () -> provider.getSecret("missing")); + } + + @Test + public void getSecretThrowsOnHttpError() throws Exception { + BitwardenHttpClient client = mock(BitwardenHttpClient.class); + when(client.post(anyString(), anyString())).thenThrow(new IOException("connection refused")); + + BitwardenSecretsProvider provider = provider(client); + assertThrows(GeneralException.class, () -> provider.getSecret("mykey")); + } + + // -- helpers -- + + private BitwardenSecretsProvider provider(BitwardenHttpClient client) throws GeneralException { + return providerWithPrefix(client, ""); + } + + private BitwardenSecretsProvider providerWithPrefix(BitwardenHttpClient client, String prefix) + throws GeneralException { + return new BitwardenSecretsProvider(client, API_URL, IDENTITY_URL, + ORG_ID, prefix, ONE_HOUR_MS, + "service-account.test-id", "test-secret", testEncKey, testMacKey); + } + + private BitwardenSecretsProvider providerWithAliases(BitwardenHttpClient client, String prefix, + Map keyAliases) throws GeneralException { + return new BitwardenSecretsProvider(client, API_URL, IDENTITY_URL, + ORG_ID, prefix, ONE_HOUR_MS, + "service-account.test-id", "test-secret", testEncKey, testMacKey, keyAliases); + } + + /** + * Builds a mock HTTP client that returns a bearer token on POST and serves a + * secrets list + secret detail using properly encrypted cipher strings. + */ + private BitwardenHttpClient mockHttpForSecret(String secretKey, String secretValue) throws Exception { + String encryptedKey = encrypt(secretKey); + String encryptedValue = encrypt(secretValue); + String secretId = "secret-id-001"; + + BitwardenHttpClient client = mock(BitwardenHttpClient.class); + when(client.post(anyString(), anyString())).thenReturn(BEARER_TOKEN_RESPONSE); + when(client.get(contains("/organizations/"), eq("test-bearer"))) + .thenReturn("{\"secrets\":[{\"id\":\"" + secretId + "\",\"key\":\"" + + encryptedKey + "\"}]}"); + when(client.get(contains("/secrets/" + secretId), eq("test-bearer"))) + .thenReturn("{\"id\":\"" + secretId + "\",\"key\":\"" + encryptedKey + + "\",\"value\":\"" + encryptedValue + "\"}"); + return client; + } + + /** Produces a valid Bitwarden type-2 cipher string using the test key material. */ + private String encrypt(String plaintext) throws Exception { + byte[] iv = new byte[16]; + new SecureRandom().nextBytes(iv); + + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(testEncKey, "AES"), new IvParameterSpec(iv)); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(testMacKey, "HmacSHA256")); + mac.update(iv); + byte[] hmac = mac.doFinal(ciphertext); + + Base64.Encoder b64 = Base64.getEncoder(); + return "2." + b64.encodeToString(iv) + "|" + + b64.encodeToString(ciphertext) + "|" + + b64.encodeToString(hmac); + } +} diff --git a/secretshub/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java new file mode 100644 index 000000000..7e01d4e0c --- /dev/null +++ b/secretshub/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java @@ -0,0 +1,127 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.envvar; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.ofbiz.base.util.GeneralException; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link EnvVarSecretProvider}. + * + *

Uses a fake {@link EnvVarReader} backed by a plain {@link Map}, since a + * running JVM cannot set real environment variables for itself. + * {@code ENC(...)} decryption itself is covered by + * {@code ConfigCryptoUtilTest}, not re-tested here — consistent with how the + * other six provider plugins' test classes treat that integration.

+ */ +public class EnvVarSecretProviderTest { + + private EnvVarSecretProvider provider(Map env) { + return providerWithPrefix(env, "OFBIZ_"); + } + + private EnvVarSecretProvider providerWithPrefix(Map env, String prefix) { + return new EnvVarSecretProvider(env::get, prefix); + } + + private EnvVarSecretProvider providerWithAliases(Map env, Map keyAliases) { + return new EnvVarSecretProvider(env::get, "OFBIZ_", keyAliases); + } + + @Test + public void getSecretReturnsValueFromEnvVar() throws Exception { + Map env = new HashMap<>(); + env.put("OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ", "demo-secret-from-vault-poc"); + + assertEquals("demo-secret-from-vault-poc", + provider(env).getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void toEnvVarNameTransformsDotsAndDashesToUnderscoresAndUppercases() { + EnvVarSecretProvider provider = provider(new HashMap<>()); + + assertEquals("OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ", + provider.toEnvVarName("jdbc-password.mysql-ofbiz")); + assertEquals("OFBIZ_PAYMENT_AUTHORIZEDOTNET_TRANKEY", + provider.toEnvVarName("payment.authorizedotnet.trankey")); + } + + @Test + public void getSecretAppliesCustomPrefix() throws Exception { + Map env = new HashMap<>(); + env.put("MYAPP_JDBC_PASSWORD_MYSQL_OFBIZ", "custom-prefixed-value"); + + assertEquals("custom-prefixed-value", + providerWithPrefix(env, "MYAPP_").getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretPassesThroughPlainValueUnchanged() throws Exception { + // Confirms the ConfigCryptoUtil.decryptIfEncrypted() call is a no-op + // for ordinary (non-ENC(...)) values - it must not mangle them. + Map env = new HashMap<>(); + env.put("OFBIZ_SOME_KEY", "plain-value-no-wrapping"); + + assertEquals("plain-value-no-wrapping", provider(env).getSecret("some.key")); + } + + // -- Key aliasing -- + + @Test + public void getSecretUsesAliasedEnvVarName() throws Exception { + Map env = new HashMap<>(); + env.put("PROD_OFBIZ_MYSQL_DB_PASSWORD", "dbpass"); + + EnvVarSecretProvider provider = providerWithAliases(env, + Map.of("jdbc-password.mysql-ofbiz", "PROD_OFBIZ_MYSQL_DB_PASSWORD")); + + assertEquals("dbpass", provider.getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretFallsBackToFixedTransformWhenNoAliasConfigured() throws Exception { + Map env = new HashMap<>(); + env.put("OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ", "dbpass"); + + EnvVarSecretProvider provider = providerWithAliases(env, + Map.of("some.other.key", "SOME_OTHER_ALIAS")); + + assertEquals("dbpass", provider.getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretThrowsWhenEnvVarNotSet() { + assertThrows(GeneralException.class, () -> provider(new HashMap<>()).getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretThrowsWhenEnvVarEmpty() { + Map env = new HashMap<>(); + env.put("OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ", ""); + + assertThrows(GeneralException.class, () -> provider(env).getSecret("jdbc-password.mysql-ofbiz")); + } +} diff --git a/secretshub/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java new file mode 100644 index 000000000..c6ebcb1ce --- /dev/null +++ b/secretshub/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java @@ -0,0 +1,175 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.gcpsecretmanager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.ofbiz.base.util.GeneralException; +import org.junit.jupiter.api.Test; + +public class GcpSecretManagerSecretsProviderTest { + + private static final long ONE_HOUR_MS = 3_600_000L; + private static final String PROJECT = "my-project"; + + // -- Happy path -- + + @Test + public void getSecretReturnsSecretValue() throws GeneralException { + GcpSecretReader reader = fixedReader( + "projects/my-project/secrets/mykey/versions/latest", "s3cr3t"); + assertEquals("s3cr3t", provider(reader, "", "-").getSecret("mykey")); + } + + @Test + public void getSecretSanitizesDotInKey() throws GeneralException { + // OFBiz key "jdbc-password.mysql-ofbiz" → GCP name "jdbc-password-mysql-ofbiz" + GcpSecretReader reader = fixedReader( + "projects/my-project/secrets/jdbc-password-mysql-ofbiz/versions/latest", "dbpass"); + assertEquals("dbpass", provider(reader, "", "-").getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretAppliesPrefixAfterSanitizing() throws GeneralException { + GcpSecretReader reader = fixedReader( + "projects/my-project/secrets/prod-jdbc-password-mydb/versions/latest", "prodpass"); + assertEquals("prodpass", provider(reader, "prod-", "-").getSecret("jdbc-password.mydb")); + } + + @Test + public void getSecretUsesConfiguredVersion() throws GeneralException { + GcpSecretReader reader = fixedReader( + "projects/my-project/secrets/mykey/versions/3", "v3val"); + GcpSecretManagerSecretsProvider p = new GcpSecretManagerSecretsProvider( + reader, PROJECT, "", "3", "-", ONE_HOUR_MS); + assertEquals("v3val", p.getSecret("mykey")); + } + + @Test + public void getSecretCachePreventsDuplicateReaderCall() throws GeneralException { + AtomicInteger calls = new AtomicInteger(); + GcpSecretReader reader = resourceName -> { + calls.incrementAndGet(); + return "val"; + }; + GcpSecretManagerSecretsProvider p = provider(reader, "", "-"); + + p.getSecret("mykey"); + p.getSecret("mykey"); // cache hit + + assertEquals(1, calls.get()); + } + + @Test + public void invalidateCacheForcesRefetchOnNextCall() throws GeneralException { + AtomicInteger calls = new AtomicInteger(); + GcpSecretReader reader = resourceName -> { + calls.incrementAndGet(); + return "val"; + }; + GcpSecretManagerSecretsProvider p = provider(reader, "", "-"); + + p.getSecret("mykey"); + p.invalidateCache(); + p.getSecret("mykey"); + + assertEquals(2, calls.get()); + } + + @Test + public void getSecretExpiredCacheTriggersRefetch() throws GeneralException { + AtomicInteger calls = new AtomicInteger(); + GcpSecretReader reader = resourceName -> { + calls.incrementAndGet(); + return "val"; + }; + // TTL of -1 ms — entries expire immediately + GcpSecretManagerSecretsProvider p = new GcpSecretManagerSecretsProvider( + reader, PROJECT, "", "latest", "-", -1L); + + p.getSecret("mykey"); + p.getSecret("mykey"); + + assertEquals(2, calls.get()); + } + + @Test + public void getSecretDotReplacementDisabledKeepsDotsInName() throws GeneralException { + // dot.replacement="" → no sanitization + GcpSecretReader reader = fixedReader( + "projects/my-project/secrets/my.key/versions/latest", "val"); + assertEquals("val", provider(reader, "", "").getSecret("my.key")); + } + + // -- Key aliasing -- + + @Test + public void getSecretUsesAliasedNameInsteadOfDotReplacement() throws GeneralException { + GcpSecretReader reader = fixedReader( + "projects/my-project/secrets/prod-ofbiz-mysql-db-password/versions/latest", "dbpass"); + GcpSecretManagerSecretsProvider p = new GcpSecretManagerSecretsProvider( + reader, PROJECT, "", "latest", "-", ONE_HOUR_MS, + Map.of("jdbc-password.mysql-ofbiz", "prod-ofbiz-mysql-db-password")); + + assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretFallsBackToDotReplacementWhenNoAliasConfigured() throws GeneralException { + GcpSecretReader reader = fixedReader( + "projects/my-project/secrets/jdbc-password-mysql-ofbiz/versions/latest", "dbpass"); + GcpSecretManagerSecretsProvider p = new GcpSecretManagerSecretsProvider( + reader, PROJECT, "", "latest", "-", ONE_HOUR_MS, + Map.of("some.other.key", "some-other-alias")); + + assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz")); + } + + // -- Error handling -- + + @Test + public void getSecretThrowsOnReaderException() { + GcpSecretReader reader = resourceName -> { + throw new Exception("NOT_FOUND"); + }; + assertThrows(GeneralException.class, () -> provider(reader, "", "-").getSecret("missing")); + } + + @Test + public void getSecretThrowsOnEmptyValue() { + GcpSecretReader reader = resourceName -> ""; + assertThrows(GeneralException.class, () -> provider(reader, "", "-").getSecret("mykey")); + } + + // -- helpers -- + + private static GcpSecretManagerSecretsProvider provider(GcpSecretReader reader, + String prefix, String dotReplacement) { + return new GcpSecretManagerSecretsProvider(reader, PROJECT, prefix, "latest", + dotReplacement, ONE_HOUR_MS); + } + + private static GcpSecretReader fixedReader(String expectedResource, String value) { + return resourceName -> expectedResource.equals(resourceName) ? value : null; + } +} diff --git a/secretshub/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java new file mode 100644 index 000000000..049ef17d7 --- /dev/null +++ b/secretshub/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java @@ -0,0 +1,183 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.hashicorpvault; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.github.jopenlibs.vault.VaultException; + +import org.apache.ofbiz.base.util.GeneralException; +import org.junit.jupiter.api.Test; + +public class HashicorpVaultSecretsProviderTest { + + private static final long ONE_HOUR_MS = 3_600_000L; + + // -- Happy path -- + + @Test + public void getSecretReturnsSingleFieldSecret() throws GeneralException { + HashicorpVaultReader reader = fixedReader("secret/mykey", singleEntry("value", "s3cr3t")); + HashicorpVaultSecretsProvider provider = provider(reader, "", ""); + + assertEquals("s3cr3t", provider.getSecret("mykey")); + } + + @Test + public void getSecretExtractsNamedField() throws GeneralException { + HashicorpVaultReader reader = fixedReader("secret/mykey", twoEntry("username", "user", "password", "s3cr3t")); + HashicorpVaultSecretsProvider provider = provider(reader, "", "password"); + + assertEquals("s3cr3t", provider.getSecret("mykey")); + } + + @Test + public void getSecretAppliesKvMountAndPrefix() throws GeneralException { + HashicorpVaultReader reader = fixedReader("kv/prod/jdbc-password.ofbiz", singleEntry("value", "dbpass")); + HashicorpVaultSecretsProvider provider = new HashicorpVaultSecretsProvider(reader, "kv", "prod/", "", ONE_HOUR_MS); + + assertEquals("dbpass", provider.getSecret("jdbc-password.ofbiz")); + } + + @Test + public void getSecretCachePreventsDuplicateReaderCall() throws GeneralException { + AtomicInteger calls = new AtomicInteger(); + HashicorpVaultReader reader = path -> { + calls.incrementAndGet(); + return singleEntry("value", "s3cr3t"); + }; + HashicorpVaultSecretsProvider provider = provider(reader, "", ""); + + provider.getSecret("mykey"); + provider.getSecret("mykey"); // second call — cache hit + + assertEquals(1, calls.get()); + } + + @Test + public void invalidateCacheForcesRefetchOnNextCall() throws GeneralException { + AtomicInteger calls = new AtomicInteger(); + HashicorpVaultReader reader = path -> { + calls.incrementAndGet(); + return singleEntry("value", "val"); + }; + HashicorpVaultSecretsProvider provider = provider(reader, "", ""); + + provider.getSecret("mykey"); + provider.invalidateCache(); + provider.getSecret("mykey"); + + assertEquals(2, calls.get()); + } + + @Test + public void getSecretExpiredCacheEntryTriggersRefetch() throws GeneralException { + AtomicInteger calls = new AtomicInteger(); + HashicorpVaultReader reader = path -> { + calls.incrementAndGet(); + return singleEntry("value", "val"); + }; + // TTL of -1 ms means entries expire immediately + HashicorpVaultSecretsProvider provider = new HashicorpVaultSecretsProvider(reader, "secret", "", "", -1L); + + provider.getSecret("mykey"); + provider.getSecret("mykey"); + + assertEquals(2, calls.get()); + } + + // -- Key aliasing -- + + @Test + public void getSecretUsesAliasedPathSegment() throws GeneralException { + HashicorpVaultReader reader = fixedReader("secret/prod/ofbiz/mysql_db_password", + singleEntry("value", "dbpass")); + HashicorpVaultSecretsProvider provider = new HashicorpVaultSecretsProvider(reader, "secret", "", "", + ONE_HOUR_MS, Map.of("jdbc-password.mysql-ofbiz", "prod/ofbiz/mysql_db_password")); + + assertEquals("dbpass", provider.getSecret("jdbc-password.mysql-ofbiz")); + } + + @Test + public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws GeneralException { + HashicorpVaultReader reader = fixedReader("secret/jdbc-password.mysql-ofbiz", + singleEntry("value", "dbpass")); + HashicorpVaultSecretsProvider provider = new HashicorpVaultSecretsProvider(reader, "secret", "", "", + ONE_HOUR_MS, Map.of("some.other.key", "some/other/alias")); + + assertEquals("dbpass", provider.getSecret("jdbc-password.mysql-ofbiz")); + } + + // -- Error handling -- + + @Test + public void getSecretThrowsWhenDataIsEmpty() { + HashicorpVaultReader reader = path -> Collections.emptyMap(); + assertThrows(GeneralException.class, () -> provider(reader, "", "").getSecret("missing")); + } + + @Test + public void getSecretThrowsWhenFieldMissing() { + HashicorpVaultReader reader = fixedReader("secret/mykey", singleEntry("username", "dbuser")); + // "password" field not present + assertThrows(GeneralException.class, () -> provider(reader, "", "password").getSecret("mykey")); + } + + @Test + public void getSecretThrowsWhenMultiFieldAndNoFieldConfigured() { + HashicorpVaultReader reader = fixedReader("secret/mykey", twoEntry("username", "u", "password", "p")); + // ambiguous — 2 fields, no field config + assertThrows(GeneralException.class, () -> provider(reader, "", "").getSecret("mykey")); + } + + @Test + public void getSecretThrowsOnVaultException() { + HashicorpVaultReader reader = path -> { + throw new VaultException("connection refused", 503); + }; + assertThrows(GeneralException.class, () -> provider(reader, "", "").getSecret("mykey")); + } + + // -- helpers -- + + private static HashicorpVaultSecretsProvider provider(HashicorpVaultReader reader, String prefix, String field) { + return new HashicorpVaultSecretsProvider(reader, "secret", prefix, field, ONE_HOUR_MS); + } + + private static HashicorpVaultReader fixedReader(String expectedPath, Map data) { + return path -> expectedPath.equals(path) ? data : Collections.emptyMap(); + } + + private static Map singleEntry(String k, String v) { + return Collections.singletonMap(k, v); + } + + private static Map twoEntry(String k1, String v1, String k2, String v2) { + Map m = new HashMap<>(); + m.put(k1, v1); + m.put(k2, v2); + return m; + } +} diff --git a/secretshub/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java new file mode 100644 index 000000000..a741f0e11 --- /dev/null +++ b/secretshub/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java @@ -0,0 +1,195 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.onepassword; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.apache.ofbiz.base.util.GeneralException; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +public class OnePasswordSecretsProviderTest { + + private static final long ONE_HOUR_MS = 3_600_000L; + private static final String BASE_URL = "http://localhost:8080"; + private static final String TOKEN = "test-token"; + private static final String VAULT_ID = "vault-abc"; + private static final String ITEM_ID = "item-1"; + + // -- Happy path -- + + @Test + public void getSecretReturnsFieldValue() throws Exception { + OnePasswordHttpClient client = buildMockClient("jdbc-password.mydb", ITEM_ID, "s3cr3t"); + assertEquals("s3cr3t", provider(client, "password", "").getSecret("jdbc-password.mydb")); + } + + @Test + public void getSecretAppliesSecretNamePrefix() throws Exception { + OnePasswordHttpClient client = buildMockClient("prod/jdbc-password.mydb", ITEM_ID, "dbpass"); + assertEquals("dbpass", provider(client, "password", "prod/").getSecret("jdbc-password.mydb")); + } + + @Test + public void getSecretCachePreventsSecondHttpCall() throws Exception { + OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "val"); + OnePasswordSecretsProvider p = provider(client, "password", ""); + + p.getSecret("mykey"); + p.getSecret("mykey"); // cache hit — no HTTP calls + + // First getSecret = 2 calls (1 search + 1 fetch); second = 0 (cache) + verify(client, times(2)).get(anyString(), anyString()); + } + + @Test + public void invalidateCacheForcesRefetch() throws Exception { + OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "val"); + OnePasswordSecretsProvider p = provider(client, "password", ""); + + p.getSecret("mykey"); + p.invalidateCache(); + p.getSecret("mykey"); // cache cleared — full re-fetch + + // Two full fetches × 2 HTTP calls each = 4 total + verify(client, times(4)).get(anyString(), anyString()); + } + + @Test + public void getSecretExpiredCacheEntryTriggersRefetch() throws Exception { + OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "val"); + OnePasswordSecretsProvider p = provider(client, "password", "", -1L); // instant expiry + + p.getSecret("mykey"); + p.getSecret("mykey"); // expired — must re-fetch + + verify(client, times(4)).get(anyString(), anyString()); + } + + // -- Key aliasing -- + + @Test + public void getSecretUsesAliasedTitle() throws Exception { + String aliasedTitle = "prod-ofbiz-mysql-db-password"; + OnePasswordHttpClient client = buildMockClient(aliasedTitle, ITEM_ID, "dbpass"); + OnePasswordSecretsProvider p = providerWithAliases(client, "password", "", + Map.of("jdbc-password.mysql-ofbiz", aliasedTitle)); + + assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz")); + + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(client, atLeastOnce()).get(urlCaptor.capture(), eq(TOKEN)); + String expectedFilter = URLEncoder.encode("title eq \"" + aliasedTitle + "\"", StandardCharsets.UTF_8); + assertTrue(urlCaptor.getAllValues().stream().anyMatch(u -> u.contains(expectedFilter))); + } + + @Test + public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws Exception { + OnePasswordHttpClient client = buildMockClient("jdbc-password.mysql-ofbiz", ITEM_ID, "dbpass"); + OnePasswordSecretsProvider p = providerWithAliases(client, "password", "", + Map.of("some.other.key", "some-other-alias")); + + assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz")); + } + + // -- Error handling -- + + @Test + public void getSecretThrowsWhenItemNotFound() throws Exception { + OnePasswordHttpClient client = mock(OnePasswordHttpClient.class); + when(client.get(anyString(), anyString())).thenReturn("[]"); + + assertThrows(GeneralException.class, () -> provider(client, "password", "").getSecret("missing")); + } + + @Test + public void getSecretThrowsWhenFieldNotPresent() throws Exception { + // Item exists but has no "password" field — only "username" + OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "username", "dbuser", "password"); + assertThrows(GeneralException.class, () -> provider(client, "password", "").getSecret("mykey")); + } + + @Test + public void getSecretThrowsOnHttpError() throws Exception { + OnePasswordHttpClient client = mock(OnePasswordHttpClient.class); + when(client.get(anyString(), anyString())).thenThrow(new IOException("connection refused")); + + assertThrows(GeneralException.class, () -> provider(client, "password", "").getSecret("mykey")); + } + + // -- helpers -- + + private static OnePasswordSecretsProvider provider(OnePasswordHttpClient client, String field, String prefix) { + return provider(client, field, prefix, ONE_HOUR_MS); + } + + private static OnePasswordSecretsProvider provider(OnePasswordHttpClient client, String field, + String prefix, long ttlMs) { + return new OnePasswordSecretsProvider(client, BASE_URL, TOKEN, VAULT_ID, field, prefix, ttlMs); + } + + private static OnePasswordSecretsProvider providerWithAliases(OnePasswordHttpClient client, String field, + String prefix, Map keyAliases) { + return new OnePasswordSecretsProvider(client, BASE_URL, TOKEN, VAULT_ID, field, prefix, ONE_HOUR_MS, + keyAliases); + } + + /** + * Builds a mock that routes by URL: search requests get the item list, + * item-fetch requests get the full item with a "password" field. + */ + private static OnePasswordHttpClient buildMockClient(String title, String itemId, String fieldValue) + throws IOException { + return buildMockClient(title, itemId, "password", fieldValue, "password"); + } + + /** + * Builds a mock that exposes an item with the given fieldLabel/fieldValue, + * regardless of which field the provider is configured to look for. + */ + private static OnePasswordHttpClient buildMockClient(String title, String itemId, + String fieldLabel, String fieldValue, String ignoredField) throws IOException { + OnePasswordHttpClient client = mock(OnePasswordHttpClient.class); + when(client.get(anyString(), eq(TOKEN))).thenAnswer(inv -> { + String url = inv.getArgument(0, String.class); + if (url.contains("filter=")) { + return "[{\"id\":\"" + itemId + "\",\"title\":\"" + title + "\"}]"; + } + // item detail + return "{\"id\":\"" + itemId + "\",\"fields\":[" + + "{\"label\":\"" + fieldLabel + "\",\"value\":\"" + fieldValue + "\"}" + + "]}"; + }); + return client; + } +} diff --git a/secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java new file mode 100644 index 000000000..2d53178df --- /dev/null +++ b/secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java @@ -0,0 +1,61 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + *******************************************************************************/ +package org.apache.ofbiz.secretshub; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import org.apache.ofbiz.envvar.EnvVarSecretProvider; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link ActiveSecretProvider}'s provider-selection/fail-fast logic. + * + *

Only {@code env-var} is exercised for the "valid name" case: its no-arg constructor + * does no network I/O, unlike the other six bundled providers (which build a remote SDK + * client or make a real HTTP call), so it is the only one safe to construct in a plain + * unit test.

+ */ +public class ActiveSecretProviderTest { + + @Test + public void buildThrowsWhenActiveIsBlank() throws Exception { + try { + ActiveSecretProvider.build(""); + fail("expected IllegalStateException for blank secret.provider.active"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("blank")); + } + } + + @Test + public void buildThrowsWhenActiveIsUnrecognized() throws Exception { + try { + ActiveSecretProvider.build("not-a-real-provider"); + fail("expected IllegalStateException for unrecognized secret.provider.active"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("not-a-real-provider")); + } + } + + @Test + public void buildReturnsMatchingProviderForValidName() throws Exception { + assertTrue(ActiveSecretProvider.build("env-var") instanceof EnvVarSecretProvider); + } +}