diff --git a/AGENTS.md b/AGENTS.md index 757f5141..fae6099b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ workflows, and service-level maintenance. - Root Maven aggregator: `pom.xml` - Services: `etl-service/`, `cdc-service/`, `zuul-gateway/`, `eureka-server/`, `config-server/` +- Config Server is independently runnable and is not a default production dependency. The default Git profile must fail closed without an operator-supplied `CONFIG_REPO_URI` before JGit runs; `native` must be the only active profile for local fixtures. See `docs/doctoring/config-server-repository-authority.md`. - Shared code: `META-INF/`, common build config in root `pom.xml` - Operations/docs: `docker/`, `docs/`, `.github/`, `scripts/` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 412d0b98..d848dfad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -588,7 +588,7 @@ ETL Processing with Retry: | ETL Service | 8000 | HTTP | Internal | | CDC Service | 8001 | HTTP | Internal | | Eureka Server | 8761 | HTTP | Internal | -| Config Server | 8888 | HTTP | Internal | +| Config Server | 8888 | HTTP | Internal; optional module, not a default Compose dependency. Default Git profile requires `CONFIG_REPO_URI` and fails closed on blank, unresolved, request-templated, or demo authority before JGit. Use `native` only as the sole active profile for local fixtures. | | PostgreSQL | 5432 | TCP | Internal | | Kafka | 9092 | TCP | Internal | | Zipkin | 9412 | HTTP | Internal | diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c3e4db..f3fece89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Config Server default Git profile now rejects unset, blank, unresolved, request-templated, and retired-demo `CONFIG_REPO_URI` values in an `EnvironmentPostProcessor` before JGit `afterPropertiesSet`. Removing the example remote is not enough: Spring Cloud Config 5.0.4 still boots with an empty URI when `cloneOnStart` is false. Set `CONFIG_REPO_URI` to a reviewed Git URI before starting the default profile. Use `native` only as the sole active profile for local fixtures. - Production container builds now use digest-pinned Docker base images while retaining readable Maven/Temurin tags, preventing upstream tag movement from silently changing reviewed build inputs. - Durable `POST /api/etl/jobs` submissions now return RFC 9110 `202 Accepted`, a stable pending-job representation, `Location` status-monitor metadata, and explicit replay metadata without changing the synchronous `/api/etl/process` contract. The incomplete intake controller is fail-closed and requires explicit `xtrmetl.etl.jobs.intake-enabled=true` operator opt-in until worker execution and terminal payload clearing are implemented. - Concurrent requests using the same authenticated-principal-scoped semantic idempotency key now return immediate RFC 9457 `409 etl_idempotency_request_in_progress` responses through PostgreSQL `pg_try_advisory_xact_lock`; retries after completion still replay the committed response. diff --git a/CLAUDE.md b/CLAUDE.md index f194a399..df0c4f7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,5 +8,6 @@ Contributor and agent guidance for this repository. - Never commit secrets, credentials, `.env` files, or private keys. - Do not commit or push unless a human explicitly asks. - For workflow edits, run local YAML parsing and `actionlint` on edited files. +- Config Server default Git profile requires `CONFIG_REPO_URI` and must fail closed on blank, unresolved, request-templated, or demo authority before JGit. `native` must be the only active profile for local fixtures. See `docs/doctoring/config-server-repository-authority.md`. If any guidance here conflicts with `AGENTS.md`, `AGENTS.md` wins. diff --git a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthority.java b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthority.java new file mode 100644 index 00000000..9cff65be --- /dev/null +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthority.java @@ -0,0 +1,154 @@ +package com.xtrmetl.config; + +import java.net.URI; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Decides whether a Config Server Git URI is an explicit deployment-owned authority. + * + *

Spring Cloud Config 5.0.4 treats a non-null empty URI as valid when + * {@code cloneOnStart} is false, so a missing or blank {@code CONFIG_REPO_URI} + * would otherwise start the process and defer Git work to the first request. + * Call this check from + * {@link ConfigServerRepositoryAuthorityEnvironmentPostProcessor} during + * default-profile startup, before JGit {@code afterPropertiesSet}.

+ * + *

Operators: set {@code CONFIG_REPO_URI} to the reviewed Git URI, then start + * the default profile. Use the {@code native} profile only as the sole active + * profile for local fixtures that must not depend on a remote.

+ */ +public final class ConfigServerRepositoryAuthority { + + static final String MISSING_AUTHORITY_MESSAGE = + "CONFIG_REPO_URI must name a deployment-owned Git repository; " + + "blank, unresolved, or demo authority is rejected before remote Git access"; + + static final String MIXED_NATIVE_PROFILE_MESSAGE = + "native profile must be the only active profile when Config Server uses local fixtures"; + + static final String REQUEST_TEMPLATE_MESSAGE = + "CONFIG_REPO_URI must be a concrete repository destination; " + + "{application}, {profile}, and {label} placeholders are rejected"; + + private static final String UNRESOLVED_PLACEHOLDER = "${CONFIG_REPO_URI"; + private static final String RETIRED_DEMO_HOST = "github.com"; + private static final String RETIRED_DEMO_PATH = "/your-repo/config-repo"; + private static final Pattern REQUEST_TEMPLATE = + Pattern.compile("\\{(application|profile|label)\\}", Pattern.CASE_INSENSITIVE); + private static final Pattern SCP_DEMO = Pattern.compile( + "(?i)(?:^|@)github\\.com[:/]your-repo/config-repo(?:\\.git)?/?$" + ); + + private ConfigServerRepositoryAuthority() { + } + + /** + * Rejects repository values that are not an operator-supplied destination. + * + * @param repositoryUri bound {@code spring.cloud.config.server.git.uri} value + * @throws IllegalStateException when the URI is missing, blank after Unicode + * trim, still a placeholder, the retired demo + * remote, or a request-templated Git location + */ + public static void requireExplicitRepository(String repositoryUri) { + if (repositoryUri == null) { + throw new IllegalStateException(MISSING_AUTHORITY_MESSAGE); + } + String trimmed = trimAuthority(repositoryUri); + if (trimmed.isEmpty() + || trimmed.contains(UNRESOLVED_PLACEHOLDER) + || isRetiredDemoRemote(trimmed)) { + throw new IllegalStateException(MISSING_AUTHORITY_MESSAGE); + } + if (REQUEST_TEMPLATE.matcher(trimmed).find()) { + throw new IllegalStateException(REQUEST_TEMPLATE_MESSAGE); + } + } + + /** + * Ensures the local-fixture {@code native} profile cannot be composed with + * another active profile to skip Git repository authority validation. + * + * @param activeProfiles explicitly active Spring profiles + * @return {@code true} when standalone native mode is active + * @throws IllegalStateException when native is combined with another profile + */ + public static boolean requireSafeProfileComposition(String... activeProfiles) { + boolean nativeActive = false; + int activeCount = 0; + for (String profile : activeProfiles) { + if (profile == null || profile.isBlank()) { + continue; + } + activeCount++; + if ("native".equalsIgnoreCase(profile.trim())) { + nativeActive = true; + } + } + if (nativeActive && activeCount != 1) { + throw new IllegalStateException(MIXED_NATIVE_PROFILE_MESSAGE); + } + return nativeActive; + } + + /** + * Removes leading and trailing Unicode space and format padding. + * + *

{@link String#isBlank()} and {@link String#strip()} miss NBSP and + * zero-width padding, which would otherwise pass as a non-empty URI.

+ * + * @param repositoryUri raw bound value + * @return value with ignorable padding removed from both ends + */ + static String trimAuthority(String repositoryUri) { + int start = 0; + int end = repositoryUri.length(); + while (start < end && isIgnorablePad(repositoryUri.codePointAt(start))) { + start += Character.charCount(repositoryUri.codePointAt(start)); + } + while (end > start) { + int codePoint = repositoryUri.codePointBefore(end); + if (!isIgnorablePad(codePoint)) { + break; + } + end -= Character.charCount(codePoint); + } + return repositoryUri.substring(start, end); + } + + static boolean isRetiredDemoRemote(String repositoryUri) { + if (SCP_DEMO.matcher(repositoryUri).find()) { + return true; + } + try { + URI uri = URI.create(repositoryUri); + String host = uri.getHost(); + String path = uri.getPath(); + if (host == null || path == null) { + return false; + } + return RETIRED_DEMO_HOST.equalsIgnoreCase(host) && isRetiredDemoPath(path); + } catch (IllegalArgumentException ex) { + return false; + } + } + + private static boolean isRetiredDemoPath(String path) { + String normalized = path.toLowerCase(Locale.ROOT); + if (normalized.endsWith(".git")) { + normalized = normalized.substring(0, normalized.length() - 4); + } + if (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return RETIRED_DEMO_PATH.equals(normalized); + } + + private static boolean isIgnorablePad(int codePoint) { + return Character.isWhitespace(codePoint) + || Character.isSpaceChar(codePoint) + || codePoint == 0x200B + || codePoint == 0xFEFF; + } +} diff --git a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java new file mode 100644 index 00000000..5f259848 --- /dev/null +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java @@ -0,0 +1,60 @@ +package com.xtrmetl.config; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +import java.util.Map; + +/** + * Rejects blank, demo, unresolved, or request-templated Git authority before + * JGit {@code afterPropertiesSet}. + * + *

Spring Cloud Config 5.0.4 constructs {@code JGitEnvironmentRepository} as + * an unordered {@code InitializingBean}. A validator bean can therefore run + * after {@code cloneOnStart=true} has already contacted a remote. This + * processor runs after config-data load and before context refresh.

+ * + *

Operators: set {@code CONFIG_REPO_URI} to a reviewed Git URI, then start + * the default profile. The {@code native} profile skips the Git URI check only + * when it is the sole active profile.

+ */ +@Order(ConfigDataEnvironmentPostProcessor.ORDER + 1) +public class ConfigServerRepositoryAuthorityEnvironmentPostProcessor implements EnvironmentPostProcessor { + + static final String PROPERTY_SOURCE_NAME = "config-server-repository-authority"; + static final String GIT_URI_PROPERTY = "spring.cloud.config.server.git.uri"; + + /** + * Fails closed on incompatible profiles or missing Git authority. + * + * @param environment configurable application environment + * @param application Spring application being prepared + */ + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + if (ConfigServerRepositoryAuthority.requireSafeProfileComposition(environment.getActiveProfiles())) { + return; + } + String repositoryUri; + try { + repositoryUri = environment.getProperty(GIT_URI_PROPERTY); + } catch (IllegalArgumentException unresolvedPlaceholder) { + throw new IllegalStateException( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + unresolvedPlaceholder + ); + } + ConfigServerRepositoryAuthority.requireExplicitRepository(repositoryUri); + String trimmed = ConfigServerRepositoryAuthority.trimAuthority(repositoryUri); + if (!trimmed.equals(repositoryUri)) { + environment.getPropertySources().addFirst(new MapPropertySource( + PROPERTY_SOURCE_NAME, + Map.of(GIT_URI_PROPERTY, trimmed) + )); + } + } +} diff --git a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java new file mode 100644 index 00000000..9a7d4fb7 --- /dev/null +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java @@ -0,0 +1,38 @@ +package com.xtrmetl.config; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** + * Defense-in-depth fail-closed check after context refresh begins. + * + *

{@link ConfigServerRepositoryAuthorityEnvironmentPostProcessor} is the + * authority that runs before JGit. This bean remains so a missing processor + * registration still stops the default profile. The {@code native} profile is + * excluded so reviewed local fixtures can start without a remote. Do not use + * {@code native} as a production fallback.

+ */ +@Component +@Profile("!native") +public class ConfigServerRepositoryAuthorityValidator implements InitializingBean { + + private final String repositoryUri; + + /** + * @param repositoryUri bound Git backend URI; empty when unset + */ + public ConfigServerRepositoryAuthorityValidator( + @Value("${spring.cloud.config.server.git.uri:}") String repositoryUri) { + this.repositoryUri = repositoryUri; + } + + /** + * Rejects blank, unresolved, or demo repository authority before Git access. + */ + @Override + public void afterPropertiesSet() { + ConfigServerRepositoryAuthority.requireExplicitRepository(repositoryUri); + } +} diff --git a/config-server/src/main/resources/META-INF/spring.factories b/config-server/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..19c74c50 --- /dev/null +++ b/config-server/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +com.xtrmetl.config.ConfigServerRepositoryAuthorityEnvironmentPostProcessor diff --git a/config-server/src/main/resources/application.yml b/config-server/src/main/resources/application.yml index e062b274..3f2fa267 100644 --- a/config-server/src/main/resources/application.yml +++ b/config-server/src/main/resources/application.yml @@ -8,7 +8,8 @@ spring: config: server: git: - uri: ${CONFIG_REPO_URI:https://github.com/your-repo/config-repo.git} + # Set CONFIG_REPO_URI to a reviewed Git URI before starting the default profile. + uri: ${CONFIG_REPO_URI} eureka: client: diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java new file mode 100644 index 00000000..949def00 --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java @@ -0,0 +1,100 @@ +package com.xtrmetl.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.mock.env.MockEnvironment; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies the pre-JGit EnvironmentPostProcessor on realistic profile and URI combinations. + */ +class ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest { + + private final ConfigServerRepositoryAuthorityEnvironmentPostProcessor processor = + new ConfigServerRepositoryAuthorityEnvironmentPostProcessor(); + private final SpringApplication application = new SpringApplication(ConfigServerApplication.class); + + @Test + void defaultProfileRejectsBlankAndDemoBeforeContextRefresh() { + MockEnvironment blank = new MockEnvironment(); + blank.setProperty("spring.cloud.config.server.git.uri", ""); + assertThrows( + IllegalStateException.class, + () -> processor.postProcessEnvironment(blank, application) + ); + + MockEnvironment demo = new MockEnvironment(); + demo.setProperty( + "spring.cloud.config.server.git.uri", + "https://github.com/your-repo/config-repo.git" + ); + assertEquals( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> processor.postProcessEnvironment(demo, application) + ).getMessage() + ); + } + + @Test + void defaultProfileAcceptsExplicitAuthorityAndTrimsPadding() { + MockEnvironment environment = new MockEnvironment(); + environment.setProperty( + "spring.cloud.config.server.git.uri", + " https://git.example.internal/config-repo.git " + ); + assertDoesNotThrow(() -> processor.postProcessEnvironment(environment, application)); + assertEquals( + "https://git.example.internal/config-repo.git", + environment.getProperty("spring.cloud.config.server.git.uri") + ); + } + + @Test + void nativeProfileSkipsGitUriCheck() { + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles("native"); + assertDoesNotThrow(() -> processor.postProcessEnvironment(environment, application)); + } + + @Test + void nativeCombinedWithAnotherProfileFailsClosed() { + MockEnvironment mixed = new MockEnvironment(); + mixed.setActiveProfiles("native", "default"); + mixed.setProperty( + "spring.cloud.config.server.git.uri", + "https://git.example.internal/config-repo.git" + ); + assertEquals( + ConfigServerRepositoryAuthority.MIXED_NATIVE_PROFILE_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> processor.postProcessEnvironment(mixed, application) + ).getMessage() + ); + } + + @Test + void unresolvedPlaceholderIsRewrittenToAuthorityFailure() { + MockEnvironment environment = new MockEnvironment() { + @Override + public String getProperty(String key) { + if ("spring.cloud.config.server.git.uri".equals(key)) { + throw new IllegalArgumentException("Could not resolve placeholder 'CONFIG_REPO_URI'"); + } + return super.getProperty(key); + } + }; + assertEquals( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> processor.postProcessEnvironment(environment, application) + ).getMessage() + ); + } +} diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java new file mode 100644 index 00000000..e863dad9 --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java @@ -0,0 +1,122 @@ +package com.xtrmetl.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.StandardEnvironment; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Starts the Config Server and proves repository/profile authority fails closed + * before a Git backend can become active, including when {@code cloneOnStart} + * is true. + * + *

Operators: set {@code CONFIG_REPO_URI} to a deployment-owned Git URI + * before starting the default profile. Use {@code native} only as the sole + * active profile for reviewed local fixtures that do not need a remote.

+ */ +class ConfigServerRepositoryAuthorityLiveTest { + + @Test + void blankRepositoryUriFailsClosedWithAuthorityMessage() { + assertTrue( + containsAuthorityFailure(runExpectingFailure( + "--spring.cloud.config.server.git.uri=", + "--spring.cloud.config.server.git.clone-on-start=true" + )), + "Blank CONFIG_REPO_URI must fail with the repository-authority message" + ); + } + + @Test + void demoRepositoryUriFailsClosedBeforeCloneOnStart() { + assertTrue( + containsAuthorityFailure(runExpectingFailure( + "--spring.cloud.config.server.git.uri=https://github.com/your-repo/config-repo.git", + "--spring.cloud.config.server.git.clone-on-start=true" + )), + "Retired demo URI must be rejected before JGit clone-on-start" + ); + } + + @Test + void unsetRepositoryUriFailsClosedWithoutInheritedEnvironmentAuthority() { + assertTrue( + containsAuthorityFailure(runExpectingFailure()), + "Unset CONFIG_REPO_URI must fail with the repository-authority message" + ); + } + + @Test + void nativeProfileCannotBeCombinedWithAnotherActiveProfile() { + Exception failure = runExpectingFailure( + "--spring.profiles.active=native,default", + "--spring.cloud.config.server.native.search-locations=classpath:/", + "--spring.cloud.config.server.git.uri=https://git.example.internal/config-repo.git" + ); + assertTrue( + messageChain(failure).contains(ConfigServerRepositoryAuthority.MIXED_NATIVE_PROFILE_MESSAGE), + () -> "Mixed native profile startup must fail closed: " + failure + ); + } + + private static Exception runExpectingFailure(String... extraArgs) { + SpringApplication application = applicationWithoutInheritedConfigRepoUri(); + ConfigurableApplicationContext context = null; + try { + context = application.run(concat(commonArgs(), extraArgs)); + fail("Config Server startup was expected to fail closed"); + throw new AssertionError("unreachable"); + } catch (Exception ex) { + return ex; + } finally { + if (context != null) { + context.close(); + } + } + } + + private static SpringApplication applicationWithoutInheritedConfigRepoUri() { + SpringApplication application = new SpringApplication(ConfigServerApplication.class); + application.setWebApplicationType(WebApplicationType.SERVLET); + StandardEnvironment environment = new StandardEnvironment(); + environment.getPropertySources().remove(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME); + application.setEnvironment(environment); + return application; + } + + private static String[] commonArgs() { + return new String[] { + "--server.port=0", + "--eureka.client.enabled=false", + "--eureka.client.register-with-eureka=false", + "--eureka.client.fetch-registry=false" + }; + } + + private static String[] concat(String[] first, String[] second) { + String[] merged = new String[first.length + second.length]; + System.arraycopy(first, 0, merged, 0, first.length); + System.arraycopy(second, 0, merged, first.length, second.length); + return merged; + } + + private static boolean containsAuthorityFailure(Throwable thrown) { + return messageChain(thrown).contains(ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE); + } + + private static String messageChain(Throwable thrown) { + assertNotNull(thrown); + StringBuilder messages = new StringBuilder(); + for (Throwable current = thrown; current != null; current = current.getCause()) { + if (current.getMessage() != null) { + messages.append(current.getMessage()).append('\n'); + } + } + return messages.toString(); + } +} diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java new file mode 100644 index 00000000..d21fd93e --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java @@ -0,0 +1,144 @@ +package com.xtrmetl.config; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Exercises the destination-authority predicate against realistic operator values. + */ +class ConfigServerRepositoryAuthorityTest { + + @Test + void rejectsMissingBlankUnresolvedAndDemoRemotes() { + assertEquals( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository(null) + ).getMessage() + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository("") + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository(" ") + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository("\u00A0\u200B") + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository("${CONFIG_REPO_URI}") + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository("${CONFIG_REPO_URI:}") + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "${CONFIG_REPO_URI:https://evil.example/config.git}" + ) + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "https://github.com/your-repo/config-repo.git" + ) + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "https://github.com/your-repo/config-repo" + ) + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "HTTPS://GITHUB.COM/YOUR-REPO/CONFIG-REPO.GIT" + ) + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "git@github.com:your-repo/config-repo.git" + ) + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "ssh://git@github.com/your-repo/config-repo.git" + ) + ); + } + + @Test + void nativeMustBeTheSoleActiveProfile() { + assertEquals( + true, + ConfigServerRepositoryAuthority.requireSafeProfileComposition("native") + ); + assertEquals( + false, + ConfigServerRepositoryAuthority.requireSafeProfileComposition() + ); + assertEquals( + ConfigServerRepositoryAuthority.MIXED_NATIVE_PROFILE_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireSafeProfileComposition("native", "default") + ).getMessage() + ); + assertEquals( + ConfigServerRepositoryAuthority.MIXED_NATIVE_PROFILE_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireSafeProfileComposition("native", "prod") + ).getMessage() + ); + } + + @Test + void rejectsRequestTemplatedGitLocations() { + assertEquals( + ConfigServerRepositoryAuthority.REQUEST_TEMPLATE_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "https://github.com/{application}/config.git" + ) + ).getMessage() + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "https://git.example.internal/{profile}/{label}.git" + ) + ); + } + + @Test + void acceptsExplicitHttpsSshFileAndNonDemoNestedPaths() { + assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "https://git.example.internal/config-repo.git" + )); + assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository( + " https://git.example.internal/config-repo.git " + )); + assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "ssh://git@git.example.internal/config-repo.git" + )); + assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "file:/opt/reviewed-config-repo" + )); + assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "https://github.com/acme/your-repo/config-repo.git" + )); + } +} diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidatorTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidatorTest.java new file mode 100644 index 00000000..2a32d6dd --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidatorTest.java @@ -0,0 +1,26 @@ +package com.xtrmetl.config; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies the startup validator delegates the same fail-closed authority check. + */ +class ConfigServerRepositoryAuthorityValidatorTest { + + @Test + void afterPropertiesSetRejectsBlankAuthority() { + ConfigServerRepositoryAuthorityValidator validator = + new ConfigServerRepositoryAuthorityValidator(""); + assertThrows(IllegalStateException.class, validator::afterPropertiesSet); + } + + @Test + void afterPropertiesSetAcceptsExplicitAuthority() { + ConfigServerRepositoryAuthorityValidator validator = + new ConfigServerRepositoryAuthorityValidator("https://git.example.internal/config-repo.git"); + assertDoesNotThrow(validator::afterPropertiesSet); + } +} diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java new file mode 100644 index 00000000..e6a04c09 --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java @@ -0,0 +1,87 @@ +package com.xtrmetl.config; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the Config Server backend authority against demo or implicit network destinations. + */ +class ConfigServerRepositoryConfigurationTest { + + @Test + void configRepositoryMustBeExplicitAndHaveNoDemoFallback() throws IOException { + String applicationYaml = Files.readString(Path.of("src/main/resources/application.yml")); + + assertTrue( + Pattern.compile("(?m)^\\s*uri:\\s*\\$\\{CONFIG_REPO_URI\\}\\s*$") + .matcher(applicationYaml) + .find(), + "Config Server must require an exact operator-supplied CONFIG_REPO_URI token" + ); + assertFalse( + applicationYaml.contains("${CONFIG_REPO_URI:"), + "Missing repository authority must not carry a YAML default colon" + ); + assertFalse( + applicationYaml.contains("your-repo/config-repo.git"), + "A demo Git repository must never be a supported runtime fallback" + ); + } + + @Test + void boot35DiscoversTheProcessorThroughSpringFactories() throws IOException { + String factories = Files.readString(Path.of( + "src/main/resources/META-INF/spring.factories" + )); + assertTrue( + factories.contains( + "com.xtrmetl.config.ConfigServerRepositoryAuthorityEnvironmentPostProcessor" + ), + "Boot 3.5.16 EnvironmentPostProcessor discovery uses META-INF/spring.factories" + ); + } + + @Test + void processorDoesNotCarryUnsupportedAlternateRegistration() { + assertFalse( + Files.exists(Path.of( + "src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor" + )), + "EnvironmentPostProcessor registration is owned by META-INF/spring.factories" + ); + } + + @Test + void repositoryAuthorityHasSourceBackedDoctoring() throws IOException { + Path doctoringPath = Path.of( + "..", + "docs", + "doctoring", + "config-server-repository-authority.md" + ); + assertTrue(Files.exists(doctoringPath), "Repository authority requires canonical source-backed doctoring"); + + String doctoring = Files.readString(doctoringPath); + assertTrue(doctoring.contains("Spring Cloud Config 5.0.4")); + assertTrue(doctoring.contains("spring.cloud.config.server.git.uri")); + assertTrue(doctoring.contains("CONFIG_REPO_URI")); + assertTrue(doctoring.contains("cloneOnStart")); + assertTrue(doctoring.contains("skipSslValidation")); + assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityValidator")); + assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityEnvironmentPostProcessor")); + assertTrue(doctoring.contains("META-INF/spring.factories")); + assertTrue(doctoring.contains("native profile must be the only active profile")); + assertTrue(doctoring.contains("unset or blank CONFIG_REPO_URI")); + assertTrue(doctoring.contains("blank")); + assertTrue(doctoring.contains("https://docs.spring.io/spring-cloud-config/reference/server/environment-repository/git-backend.html")); + assertTrue(doctoring.contains("https://docs.spring.io/spring-cloud-config/reference/server/security.html")); + assertTrue(doctoring.contains("APA 7")); + } +} diff --git a/docs/doctoring/config-server-repository-authority.md b/docs/doctoring/config-server-repository-authority.md new file mode 100644 index 00000000..2141e05a --- /dev/null +++ b/docs/doctoring/config-server-repository-authority.md @@ -0,0 +1,90 @@ +# Config Server repository authority doctoring + +**Capability status:** `active_pr` via repair of #327 / #322; not `implemented_on_develop` until protected integration. +**Protected baseline assessed:** `develop@e8373b7193019e72b7a860c9f14d109fe7963ee7` +**Component:** Spring Cloud Config 5.0.4, managed through the repository's Spring Cloud 2025.0.3 release train + +```mermaid +flowchart TD + start[Start Config Server] --> native{native profile active?} + native -->|yes| mixed{any other profile also active?} + mixed -->|yes| failNative[Stop. native must be the only active profile for local fixtures] + mixed -->|no| fixtures[Continue native fixtures] + native -->|no| uri{CONFIG_REPO_URI a reviewed concrete Git destination?} + uri -->|blank, unresolved, demo, or template| failUri[Stop. Export CONFIG_REPO_URI to the reviewed repository, then start again] + uri -->|yes| git[Bind the Git backend] +``` + +## Decision boundary + +mightyETL's independently runnable Config Server Git backend must have explicit deployment-owned repository authority. The deployable property `spring.cloud.config.server.git.uri` uses only `${CONFIG_REPO_URI}` and has no example, demo, guessed, or implicit remote fallback. Missing, blank, unresolved, or demo repository authority must fail closed at default-profile startup, before remote Git access. + +This is a narrow destination-authority decision, not a claim that Config Server is part of the default supported topology. The protected Compose profile does not establish Config Server as a required production dependency. A later product decision may promote, constrain, or retire the module, but none of those outcomes requires retaining a fake repository fallback. + +Operators: export `CONFIG_REPO_URI` to the reviewed Git URI, then start the default profile. Use the `native` profile only as the sole active profile for local fixtures that must not depend on a remote. Do not start the default profile with an unset or blank `CONFIG_REPO_URI`. Git credentials are a separate secret. + +## Root cause and rejected alternatives + +The prior configuration used a syntactically valid default resembling `https://github.com/your-repo/config-repo.git`. That deferred a missing deployment decision into an outbound network attempt while providing no real ownership, provenance, credential, availability, change-control, or recovery contract. + +Removing the demo default alone is not fail-closed. On Spring Cloud Config 5.0.4, `JGitEnvironmentRepository.afterPropertiesSet` accepts a non-null empty URI when `cloneOnStart` is false. Local evidence on `50ddcc0` showed both unset and blank `CONFIG_REPO_URI` starting Tomcat and exposing `/actuator` before any Git work. Spring also left `${CONFIG_REPO_URI}` unresolved instead of failing Environment bootstrap, so a YAML token change cannot be the only control. + +Rejected alternatives: + +- invent a ContextualWisdomLab repository URL, private endpoint, username, password, token, SSH key, certificate, or trust root; +- retain the demo URI because operators can override it later; +- embed credentials in `CONFIG_REPO_URI` or repository source; +- enable `skipSslValidation` to make an untrusted repository reachable; +- discover repository authority through mutable remote bootstrap code; +- treat repository credentials as proof that a destination is authorized; +- treat a YAML `String.contains` test as live fail-closed evidence. + +The selected remedy keeps the explicit `${CONFIG_REPO_URI}` token and registers `ConfigServerRepositoryAuthorityEnvironmentPostProcessor` in `META-INF/spring.factories` after config-data load. Spring Boot 3.5 and the current Spring Boot 4 `EnvironmentPostProcessor` contract both register implementations through `META-INF/spring.factories`; the unsupported alternate `META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor` resource is intentionally absent. Spring Boot 4 also moves the interface from `org.springframework.boot.env.EnvironmentPostProcessor` to `org.springframework.boot.EnvironmentPostProcessor`, so any Boot 4 upgrade must update the Java import and `spring.factories` key together rather than adding a second registry. The processor rejects blank, Unicode-padded blank, unresolved `${CONFIG_REPO_URI...}`, request-templated `{application}` / `{profile}` / `{label}` locations, and the retired `github.com/your-repo/config-repo` destination (any case, with or without `.git`, HTTPS, `ssh://`, or `git@`) before JGit `afterPropertiesSet`. `ConfigServerRepositoryAuthorityValidator` remains as defense in depth on every non-`native` profile. Combining `native` with any other active profile fails closed; the `native` profile must be the only active profile. Config Server HTTP authentication, Git credentials, trust material, retry and timeout policy, readiness, repository support status, and service-to-service identity remain separate controls. + +## Spring Cloud Config 5.0.4 contract + +Spring Cloud Config documents `spring.cloud.config.server.git.uri` as the Git environment-repository location. Local filesystem repositories can be useful for deliberately configured development or testing, while production repository location and access remain deployment decisions. mightyETL externalizes this authority through `CONFIG_REPO_URI` rather than assigning a product-owned demo default. + +`cloneOnStart` can surface an invalid repository during startup instead of first request. This repair still does not enable it, because fail-early cloning changes startup and network-availability semantics beyond destination authority. Blank-authority rejection is a local configuration check and does not need a clone. If Config Server becomes a supported profile, fail-early cloning must be evaluated together with bounded connect/fetch timeouts, readiness, retry, recovery, and SLO policy. + +TLS certificate validation remains enabled. The documented `skipSslValidation` escape hatch must not become a convenience default. Private Git services needing custom roots require explicit trust-material provenance and rotation rather than global certificate-verification bypass. + +Git credentials are deployment secrets, not repository authority. They must be externalized through supported secret-management controls, be least-privileged to the approved repository, and stay out of source, committed URLs, ordinary logs, metrics, traces, pull-request text, and generated documentation. + +## Failure, privacy, and operability semantics + +A missing or blank `CONFIG_REPO_URI` on the default profile is a deployment-configuration failure. The process must stop with a finite message that names `CONFIG_REPO_URI`. An unset or blank CONFIG_REPO_URI is rejected before remote Git access. That failure is safer and more actionable than contacting an invented external repository or serving misleading configuration from an empty URI. + +The `native` profile remains independently startable without `CONFIG_REPO_URI` so inbound-security fixtures and local filesystem backends keep working. Do not use `native` to bypass destination authority in a composed production topology. A mixed native composition fails with: native profile must be the only active profile when Config Server uses local fixtures. + +Repository URLs and provider exceptions can expose internal hostnames, usernames, paths, query parameters, or credentials. Ordinary observability should retain finite configuration and fetch outcome classifications rather than raw credential-bearing URLs or unrestricted exception text. This is purpose-bound diagnostic minimization, not blanket PII masking. + +Rollback must never restore the demo remote. When an approved repository is unavailable, the safe choices are to disable or not deploy Config Server, restore the approved repository service, or supply another explicitly authorized repository through a reviewed deployment change. A deliberate local-development `file:` URI belongs in an explicit operator-supplied `CONFIG_REPO_URI` or the `native` profile, not in a production fallback. + +## Standalone and modular MSA implications + +The Config Server module remains independently buildable. ETL, CDC, gateway, and discovery services must not silently become dependent on it merely because the module exists. A composed MSA profile that adopts Config Server needs explicit dependency and failure-domain documentation, inbound authentication, repository authority, trust and credential provenance, readiness, rollback, and tests proving promised standalone modes remain available. + +No central orchestrator, gateway, or sibling service acquires authority to rewrite Config Server destination configuration at runtime without a versioned, authenticated deployment contract. + +## Evidence and replacement lineage + +`ConfigServerRepositoryAuthorityLiveTest` starts `ConfigServerApplication` on the default servlet profile after removing inherited `systemEnvironment`, so a runner-level `CONFIG_REPO_URI` cannot satisfy the unset case. Blank and retired-demo URIs with `clone-on-start=true` must fail with the authority message, which is the evidence that the processor ran before JGit clone. Unset `CONFIG_REPO_URI` must fail with that same finite authority message, not an unrelated placeholder error. `native,default` must fail because the `native` profile must be the only active profile. `ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest` covers blank, demo, trim, native skip, mixed native, and unresolved-placeholder rewrite. `ConfigServerRepositoryAuthorityTest` covers null, ASCII blank, NBSP/ZWSP, `${CONFIG_REPO_URI:}` defaults, case and no-`.git` demo variants, `git@` and `ssh://` demo, request templates, padded `https`, `ssh`, `file:`, a non-demo nested path, and mixed native composition. `ConfigServerRepositoryConfigurationTest` pins the exact YAML token with no default colon, `META-INF/spring.factories` discovery, and absence of the unsupported alternate processor-registration resource. Old PR #189 / #322 / #327 must not merge separately after this unique work is accepted. + +Canonical PRD, TRD, Architecture, UML, Security, Threat Model, Operability, and Traceability must represent this capability as `active_pr` until protected integration. The documentation must not infer that Config Server became a shipped default component merely because repository authority was hardened. + +## References (APA 7) + +Spring Boot. (2026). *Externalized configuration*. https://docs.spring.io/spring-boot/reference/features/external-config.html + +Spring Cloud Config. (2026). *Config Server (Spring Cloud Config 5.0.4)*. https://docs.spring.io/spring-cloud-config/reference/server.html + +Spring Cloud Config. (2026). *Git backend (Spring Cloud Config 5.0.4)*. https://docs.spring.io/spring-cloud-config/reference/server/environment-repository/git-backend.html + +Spring Boot. (2026). *EnvironmentPostProcessor*. https://docs.spring.io/spring-boot/api/java/org/springframework/boot/env/EnvironmentPostProcessor.html + +Spring Boot. (2026). *Spring Boot 4.0 migration guide*. https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide + +Spring Boot. (2026). *SpringApplication*. https://docs.spring.io/spring-boot/reference/features/spring-application.html + +Spring Cloud Config. (2026). *Security (Spring Cloud Config 5.0.4)*. https://docs.spring.io/spring-cloud-config/reference/server/security.html