diff --git a/gradle.properties b/gradle.properties
index 3d5849d7a2..bb94dbc9c4 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.3
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..dcb4bdbf1d
--- /dev/null
+++ b/server/embedded/src/org/labkey/embedded/AwsParameterStoreEnvironmentPostProcessor.java
@@ -0,0 +1,296 @@
+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:
+ *
+ * - An {@code ssm:} reference names a parameter that does not exist in Parameter Store
+ * - The AWS SDK cannot obtain credentials (no IAM role, no env vars, no profile)
+ * - {@code context.awsParameterStore.prefix} or the resolved {@code secretsPrefix} does not
+ * end with {@code /} or {@code ::}
+ *
+ */
+@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 = "/";
+ 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) {}
+
+ @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);
+
+ if (!prefix.endsWith("/") && !prefix.endsWith("::"))
+ throw new IllegalStateException(
+ "[LabKey AWS] " + PREFIX_PROPERTY + " must end with '/' or '::' (got: '" + prefix + "')");
+
+ // 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
+ // only need to configure the deployment prefix once.
+ secretsPrefix = secretsPrefix.startsWith("/") ? secretsPrefix : prefix + secretsPrefix;
+ }
+ else
+ {
+ secretsPrefix = prefix;
+ }
+
+ if (!secretsPrefix.isEmpty() && !secretsPrefix.endsWith("/") && !secretsPrefix.endsWith("::"))
+ throw new IllegalStateException(
+ "[LabKey AWS] Resolved secretsPrefix must end with '/' or '::' (got: '" + secretsPrefix +
+ "'). Check " + SECRETS_PREFIX_PROPERTY + " and " + PREFIX_PROPERTY + " in application.properties");
+
+ 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);
+
+ if (ssmRefs.isEmpty())
+ return;
+
+ System.out.println("[LabKey AWS] Initializing SSM client (prefix=" + prefix + ")");
+
+ try (SsmClient ssmClient = buildSsmClient(region))
+ {
+ 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 = OBJECT_MAPPER.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);
+ }
+ }
+}