From 927e0d1ff7e9e5054f2b435cd13a26a6e2ef04d8 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Thu, 21 May 2026 09:01:35 -0700 Subject: [PATCH 1/4] Support SSM-backed properties in application.properties and secrets --- server/embedded/build.gradle | 4 + .../main/resources/META-INF/spring.factories | 2 + ...arameterStoreEnvironmentPostProcessor.java | 276 ++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 server/embedded/src/main/resources/META-INF/spring.factories create mode 100644 server/embedded/src/org/labkey/embedded/AwsParameterStoreEnvironmentPostProcessor.java diff --git a/server/embedded/build.gradle b/server/embedded/build.gradle index b3ea6acce5..c6747e6196 100644 --- a/server/embedded/build.gradle +++ b/server/embedded/build.gradle @@ -131,6 +131,10 @@ dependencies { exclude group: 'commons-logging', module: 'commons-logging' } + implementation "software.amazon.awssdk:ssm:${awsSdkVersion}" + implementation "software.amazon.awssdk:sso:${awsSdkVersion}" + implementation "software.amazon.awssdk:ssooidc:${awsSdkVersion}" + implementation "commons-io:commons-io:${commonsIoVersion}" implementation "org.apache.logging.log4j:log4j-core:${log4j2Version}" diff --git a/server/embedded/src/main/resources/META-INF/spring.factories b/server/embedded/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..1b31419260 --- /dev/null +++ b/server/embedded/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.EnvironmentPostProcessor=\ + org.labkey.embedded.AwsParameterStoreEnvironmentPostProcessor diff --git a/server/embedded/src/org/labkey/embedded/AwsParameterStoreEnvironmentPostProcessor.java b/server/embedded/src/org/labkey/embedded/AwsParameterStoreEnvironmentPostProcessor.java new file mode 100644 index 0000000000..dadf1464cf --- /dev/null +++ b/server/embedded/src/org/labkey/embedded/AwsParameterStoreEnvironmentPostProcessor.java @@ -0,0 +1,276 @@ +package org.labkey.embedded; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jspecify.annotations.NonNull; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; +import software.amazon.awssdk.services.ssm.SsmClient; +import software.amazon.awssdk.services.ssm.model.ParameterNotFoundException; +import software.amazon.awssdk.services.ssm.model.SsmException; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Resolves {@code ssm:/path/to/param} placeholder values in Spring properties at startup. + * + *

When a property value in {@code application.properties} begins with {@code ssm:}, this + * post-processor fetches the actual value from AWS Systems Manager Parameter Store and injects + * it as a high-priority property source before any {@code @ConfigurationProperties} beans are + * bound. Example: + * + *

+ * context.resources.jdbc.labkeyDataSource.password=ssm:/acme/web1/jdbc.json::password
+ * context.encryptionKey=ssm:/acme/web1/encryptionKey
+ * 
+ * + *

A path without a leading {@code /} is relative and is resolved against + * {@code context.awsParameterStore.prefix}, so the prefix only needs to be specified once: + * + *

+ * context.awsParameterStore.prefix=/acme/web1/
+ * context.resources.jdbc.labkeyDataSource.password=ssm:jdbc.json::password
+ * context.encryptionKey=ssm:encryptionKey
+ * 
+ * + *

When an SSM parameter holds a JSON object, append {@code ::dotPath} to address a specific + * key. Dot notation supports arbitrary nesting: + * + *

+ * context.resources.jdbc.labkeyDataSource.password=ssm:jdbc.json::password
+ * context.resources.jdbc.labkeyDataSource.host=ssm:jdbc.json::host
+ * context.foo=ssm:config.json::level1.level2.value
+ * 
+ * + *

Multiple properties that reference different keys within the same JSON parameter will fetch + * that parameter exactly once. + * + *

SSM initialization also runs when {@code context.awsParameterStore.prefix} is explicitly + * configured (even with no {@code ssm:} values), so that the CloudServices module can create its + * own {@code SsmClient} for on-demand {@code SecretService} lookups. When active, this processor + * publishes {@code labkey.aws.ssm.region} and optionally {@code labkey.aws.ssm.secretsPrefix} as + * JVM system properties without any cross-classloader reflection. + * + *

{@code context.awsParameterStore.secretsPrefix} controls where {@code SecretProperty} + * values are looked up at runtime. A relative value (no leading {@code /}) is resolved against + * the main prefix. A prefix ending with {@code ::} points to a JSON blob; the property name is + * used as the key within that blob: + * + *

+ * context.awsParameterStore.prefix=/acme/web1/
+ * # Relative — effective prefix becomes /acme/web1/app-secrets.json::
+ * context.awsParameterStore.secretsPrefix=app-secrets.json::
+ * 
+ * + *

On-premise deployments with no {@code ssm:} values and no explicit + * {@code context.awsParameterStore.prefix} or {@code context.awsParameterStore.secretsPrefix} + * configured incur zero overhead — no AWS SDK classes are initialized and no network calls are made. + * + *

Hard failures occur when: + *

+ */ +@SuppressWarnings("SSBasedInspection") // This runs before Log4J is initialized, so use System.out and System.err +public class AwsParameterStoreEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered +{ + private static final String SSM_SCHEME = "ssm:"; + private static final String JSON_SEPARATOR = "::"; + private static final String PREFIX_PROPERTY = "context.awsParameterStore.prefix"; + private static final String SECRETS_PREFIX_PROPERTY = "context.awsParameterStore.secretsPrefix"; + private static final String REGION_PROPERTY = "context.awsParameterStore.region"; + private static final String DEFAULT_PREFIX = "/"; + + /** Parsed reference to an SSM parameter, optionally with a dot-path into a JSON value. */ + private record SsmRef(String paramPath, String jsonPath) {} + + @Override + public int getOrder() + { + return Ordered.LOWEST_PRECEDENCE - 10; + } + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) + { + String prefix = environment.getProperty(PREFIX_PROPERTY, DEFAULT_PREFIX); + String secretsPrefix = environment.getProperty(SECRETS_PREFIX_PROPERTY); + + if (secretsPrefix != null) + { + // A relative path (no leading /) is resolved against the main prefix so operators + // only need to configure the deployment prefix once. + secretsPrefix = secretsPrefix.startsWith("/") ? secretsPrefix : prefix + secretsPrefix; + } + else + { + secretsPrefix = prefix; + } + + String regionOverride = environment.getProperty(REGION_PROPERTY); + Region region = resolveRegion(regionOverride); + + // Publish config as system properties so the CloudServices module can create its own + // SsmClient for on-demand SecretProperty lookups via SecretService at runtime. + System.setProperty("labkey.aws.ssm.region", region.id()); + System.setProperty("labkey.aws.ssm.secretsPrefix", secretsPrefix); + + Map ssmRefs = findSsmReferences(environment, prefix); + if (ssmRefs.isEmpty()) + return; + + System.out.println("[LabKey AWS] Initializing SSM client (prefix=" + prefix + ")"); + + try (SsmClient ssmClient = buildSsmClient(region)) + { + if (!ssmRefs.isEmpty()) + { + Map resolved = new LinkedHashMap<>(); + // Fetch each unique SSM parameter path once to avoid redundant calls when multiple + // properties reference different JSON keys within the same parameter. + Map fetchedParams = new LinkedHashMap<>(); + for (Map.Entry entry : ssmRefs.entrySet()) + { + String propName = entry.getKey(); + SsmRef ref = entry.getValue(); + String rawValue = fetchedParams.computeIfAbsent(ref.paramPath(), p -> fetchRequired(ssmClient, p)); + String resolvedValue = ref.jsonPath() != null ? extractFromJson(rawValue, ref.jsonPath(), ref.paramPath()) : rawValue; + resolved.put(propName, resolvedValue); + System.out.println("[LabKey AWS] Resolved property '" + propName + "' from SSM parameter '" + ref.paramPath() + + (ref.jsonPath() != null ? "::" + ref.jsonPath() : "") + "'"); + } + environment.getPropertySources().addFirst(new MapPropertySource("awsParameterStore", resolved)); + } + } + } + + private Map findSsmReferences(ConfigurableEnvironment environment, String prefix) + { + Map refs = new LinkedHashMap<>(); + for (PropertySource source : environment.getPropertySources()) + { + if (source instanceof EnumerablePropertySource enumerable) + { + for (String name : enumerable.getPropertyNames()) + { + Object raw = source.getProperty(name); + if (raw instanceof String str && str.startsWith(SSM_SCHEME)) + { + SsmRef ref = getSsmRef(prefix, str); + refs.putIfAbsent(name, ref); + } + } + } + } + return refs; + } + + private static @NonNull SsmRef getSsmRef(String prefix, String str) + { + String withoutScheme = str.substring(SSM_SCHEME.length()); + // A relative path (no leading /) is resolved against the configured prefix. + if (!withoutScheme.startsWith("/")) + withoutScheme = prefix + withoutScheme; + int sepIdx = withoutScheme.indexOf(JSON_SEPARATOR); + return sepIdx >= 0 + ? new SsmRef(withoutScheme.substring(0, sepIdx), withoutScheme.substring(sepIdx + JSON_SEPARATOR.length())) + : new SsmRef(withoutScheme, null); + } + + private String extractFromJson(String json, String dotPath, String paramName) + { + JsonNode node; + try + { + node = new ObjectMapper().readTree(json); + } + catch (JsonProcessingException e) + { + throw new IllegalStateException( + "[LabKey AWS] SSM parameter '" + paramName + "' value is not valid JSON: " + e.getMessage(), e); + } + for (String segment : dotPath.split("\\.")) + { + node = node.get(segment); + if (node == null) + throw new IllegalStateException( + "[LabKey AWS] SSM parameter '" + paramName + "' JSON does not contain key path '" + dotPath + "' (missing segment '" + segment + "')"); + } + if (!node.isValueNode()) + throw new IllegalStateException( + "[LabKey AWS] SSM parameter '" + paramName + "' path '" + dotPath + "' does not resolve to a scalar value"); + return node.asText(); + } + + private Region resolveRegion(String regionOverride) + { + if (regionOverride != null && !regionOverride.isBlank()) + { + Region region = Region.of(regionOverride); + System.out.println("[LabKey AWS] Using explicit region: " + region); + return region; + } + try + { + Region region = new DefaultAwsRegionProviderChain().getRegion(); + System.out.println("[LabKey AWS] Using auto-detected region: " + region + + " (override with context.awsParameterStore.region or AWS_REGION env var)"); + return region; + } + catch (Exception e) + { + throw new IllegalStateException( + "[LabKey AWS] Failed to determine AWS region. Set context.awsParameterStore.region " + + "in application.properties or the AWS_REGION environment variable: " + e.getMessage(), e); + } + } + + private SsmClient buildSsmClient(Region region) + { + try + { + return SsmClient.builder() + .region(region) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } + catch (Exception e) + { + throw new IllegalStateException( + "[LabKey AWS] Failed to initialize SSM client. Ensure AWS credentials are " + + "available (IAM instance role, ECS task role, or environment variables): " + + e.getMessage(), e); + } + } + + private String fetchRequired(SsmClient client, String paramName) + { + try + { + return client.getParameter(r -> r.name(paramName).withDecryption(true)) + .parameter().value(); + } + catch (ParameterNotFoundException e) + { + throw new IllegalStateException( + "[LabKey AWS] Required SSM parameter '" + paramName + "' was not found. " + + "Create it in AWS Systems Manager > Parameter Store.", e); + } + catch (SsmException e) + { + throw new IllegalStateException( + "[LabKey AWS] Failed to fetch SSM parameter '" + paramName + "': " + e.getMessage(), e); + } + } +} From b7a3b112f6085ceacdfac1dffb512dfbd28209cb Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Thu, 21 May 2026 09:01:53 -0700 Subject: [PATCH 2/4] Support SSM-backed properties in application.properties and secrets --- gradle.properties | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gradle.properties b/gradle.properties index 54074f9b69..0bbeee20e1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -104,6 +104,8 @@ apacheTomcatVersion=11.0.22 # tika asmVersion=9.9.1 +awsSdkVersion=2.29.50 + # Microsoft library for sending OAuth2-authenticated notification emails via the Microsoft Graph API azureIdentityVersion=1.18.2 From d84b6fb3d9dab88e2ff8e72b43e867d5b107ba87 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Thu, 21 May 2026 11:37:24 -0700 Subject: [PATCH 3/4] Assorted fixes --- ...arameterStoreEnvironmentPostProcessor.java | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/server/embedded/src/org/labkey/embedded/AwsParameterStoreEnvironmentPostProcessor.java b/server/embedded/src/org/labkey/embedded/AwsParameterStoreEnvironmentPostProcessor.java index dadf1464cf..843cb8f01f 100644 --- a/server/embedded/src/org/labkey/embedded/AwsParameterStoreEnvironmentPostProcessor.java +++ b/server/embedded/src/org/labkey/embedded/AwsParameterStoreEnvironmentPostProcessor.java @@ -91,6 +91,7 @@ public class AwsParameterStoreEnvironmentPostProcessor implements EnvironmentPos private static final String SECRETS_PREFIX_PROPERTY = "context.awsParameterStore.secretsPrefix"; private static final String REGION_PROPERTY = "context.awsParameterStore.region"; private static final String DEFAULT_PREFIX = "/"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); /** Parsed reference to an SSM parameter, optionally with a dot-path into a JSON value. */ private record SsmRef(String paramPath, String jsonPath) {} @@ -105,8 +106,20 @@ public int getOrder() public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { String prefix = environment.getProperty(PREFIX_PROPERTY, DEFAULT_PREFIX); - String secretsPrefix = environment.getProperty(SECRETS_PREFIX_PROPERTY); + // Scan for "ssm:" references in the values of any property, which is an instruction to get the real value + // from AWS's SSM parameter store + Map ssmRefs = findSsmReferences(environment, prefix); + + // Determine whether the operator explicitly opted into AWS config. + boolean hasExplicitConfig = environment.containsProperty(PREFIX_PROPERTY) || + environment.containsProperty(SECRETS_PREFIX_PROPERTY); + + // On-premise deployments with no explicit AWS config and no ssm: values incur zero overhead. + if (!hasExplicitConfig && ssmRefs.isEmpty()) + return; + + String secretsPrefix = environment.getProperty(SECRETS_PREFIX_PROPERTY); if (secretsPrefix != null) { // A relative path (no leading /) is resolved against the main prefix so operators @@ -126,7 +139,6 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp System.setProperty("labkey.aws.ssm.region", region.id()); System.setProperty("labkey.aws.ssm.secretsPrefix", secretsPrefix); - Map ssmRefs = findSsmReferences(environment, prefix); if (ssmRefs.isEmpty()) return; @@ -134,24 +146,21 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp try (SsmClient ssmClient = buildSsmClient(region)) { - if (!ssmRefs.isEmpty()) + Map resolved = new LinkedHashMap<>(); + // Fetch each unique SSM parameter path once to avoid redundant calls when multiple + // properties reference different JSON keys within the same parameter. + Map fetchedParams = new LinkedHashMap<>(); + for (Map.Entry entry : ssmRefs.entrySet()) { - Map resolved = new LinkedHashMap<>(); - // Fetch each unique SSM parameter path once to avoid redundant calls when multiple - // properties reference different JSON keys within the same parameter. - Map fetchedParams = new LinkedHashMap<>(); - for (Map.Entry entry : ssmRefs.entrySet()) - { - String propName = entry.getKey(); - SsmRef ref = entry.getValue(); - String rawValue = fetchedParams.computeIfAbsent(ref.paramPath(), p -> fetchRequired(ssmClient, p)); - String resolvedValue = ref.jsonPath() != null ? extractFromJson(rawValue, ref.jsonPath(), ref.paramPath()) : rawValue; - resolved.put(propName, resolvedValue); - System.out.println("[LabKey AWS] Resolved property '" + propName + "' from SSM parameter '" + ref.paramPath() + - (ref.jsonPath() != null ? "::" + ref.jsonPath() : "") + "'"); - } - environment.getPropertySources().addFirst(new MapPropertySource("awsParameterStore", resolved)); + String propName = entry.getKey(); + SsmRef ref = entry.getValue(); + String rawValue = fetchedParams.computeIfAbsent(ref.paramPath(), p -> fetchRequired(ssmClient, p)); + String resolvedValue = ref.jsonPath() != null ? extractFromJson(rawValue, ref.jsonPath(), ref.paramPath()) : rawValue; + resolved.put(propName, resolvedValue); + System.out.println("[LabKey AWS] Resolved property '" + propName + "' from SSM parameter '" + ref.paramPath() + + (ref.jsonPath() != null ? "::" + ref.jsonPath() : "") + "'"); } + environment.getPropertySources().addFirst(new MapPropertySource("awsParameterStore", resolved)); } } @@ -193,7 +202,7 @@ private String extractFromJson(String json, String dotPath, String paramName) JsonNode node; try { - node = new ObjectMapper().readTree(json); + node = OBJECT_MAPPER.readTree(json); } catch (JsonProcessingException e) { From a12a5c1f30362e0d159b729bc27e1655becefb7c Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Fri, 22 May 2026 11:23:48 -0700 Subject: [PATCH 4/4] Misc improvements --- .idea/inspectionProfiles/Project_Default.xml | 5 +++++ .../AwsParameterStoreEnvironmentPostProcessor.java | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 085e672a5e..e5a5dcb159 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -16,6 +16,7 @@ +