diff --git a/AGENTS.md b/AGENTS.md index 757f5141..12670aaa 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`; 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..f2cfef3a 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, or demo authority. Use `native` only 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..8bb5af7b 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, and demo `CONFIG_REPO_URI` values at startup. 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 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..45eb7223 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 authority. 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..8fe10549 --- /dev/null +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthority.java @@ -0,0 +1,97 @@ +package com.xtrmetl.config; + +import java.net.URI; +import java.util.Locale; + +/** + * 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 during environment preparation, before Config Server creates + * its Git repository beans.

+ * + *

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"; + + 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 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, still a + * placeholder, or the retired demo remote + */ + public static void requireExplicitRepository(String repositoryUri) { + if (repositoryUri == null || repositoryUri.isBlank()) { + throw new IllegalStateException(MISSING_AUTHORITY_MESSAGE); + } + String trimmed = repositoryUri.trim(); + if (trimmed.contains(UNRESOLVED_PLACEHOLDER) || isRetiredDemoRemote(trimmed)) { + throw new IllegalStateException(MISSING_AUTHORITY_MESSAGE); + } + } + + /** + * Ensures the local-fixture {@code native} profile cannot be composed with + * another active profile to bypass 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; + } + + private static boolean isRetiredDemoRemote(String repositoryUri) { + try { + URI uri = URI.create(repositoryUri); + if (!RETIRED_DEMO_HOST.equalsIgnoreCase(uri.getHost())) { + return false; + } + String path = uri.getPath(); + if (path == null) { + return false; + } + String normalizedPath = path.toLowerCase(Locale.ROOT); + while (normalizedPath.endsWith("/") && normalizedPath.length() > 1) { + normalizedPath = normalizedPath.substring(0, normalizedPath.length() - 1); + } + return RETIRED_DEMO_PATH.equals(normalizedPath) + || (RETIRED_DEMO_PATH + ".git").equals(normalizedPath); + } catch (IllegalArgumentException ignored) { + return false; + } + } +} 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..d1ac9c7a --- /dev/null +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java @@ -0,0 +1,63 @@ +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.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * Validates Config Server repository authority after config data is loaded and + * before the application context creates Spring Cloud Config Git components. + * + *

This boundary intentionally runs before bean initialization so + * {@code clone-on-start=true} cannot contact an unreviewed or retired remote. + * Standalone {@code native} mode remains available for reviewed local fixtures, + * but native cannot be combined with another active profile.

+ */ +public final class ConfigServerRepositoryAuthorityEnvironmentPostProcessor + implements EnvironmentPostProcessor, Ordered { + + private static final String REPOSITORY_URI_PROPERTY = + "spring.cloud.config.server.git.uri"; + + /** + * Runs immediately after Spring Boot has loaded normal ConfigData. + * + * @return the processor order + */ + @Override + public int getOrder() { + return ConfigDataEnvironmentPostProcessor.ORDER + 1; + } + + /** + * Enforces profile composition and Git destination authority before context refresh. + * + * @param environment prepared Spring environment + * @param application application being started + */ + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, + SpringApplication application + ) { + boolean nativeOnly = ConfigServerRepositoryAuthority.requireSafeProfileComposition( + environment.getActiveProfiles() + ); + if (nativeOnly) { + return; + } + + String repositoryUri; + try { + repositoryUri = environment.getProperty(REPOSITORY_URI_PROPERTY); + } catch (IllegalArgumentException unresolvedPlaceholder) { + throw new IllegalStateException( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + unresolvedPlaceholder + ); + } + ConfigServerRepositoryAuthority.requireExplicitRepository(repositoryUri); + } +} 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..8c4708fe --- /dev/null +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java @@ -0,0 +1,35 @@ +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; + +/** + * Fails default-profile startup when Git repository authority is not explicit. + * + *

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/ConfigServerRepositoryAuthorityLiveTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java new file mode 100644 index 00000000..2f8897c2 --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java @@ -0,0 +1,130 @@ +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. + * + *

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 blankRepositoryUriFailsClosedBeforeGitAccess() { + Exception failure = runExpectingFailure( + applicationWithoutInheritedConfigRepoUri(), + "--server.port=0", + "--eureka.client.enabled=false", + "--eureka.client.register-with-eureka=false", + "--eureka.client.fetch-registry=false", + "--spring.cloud.config.server.git.uri=", + "--spring.cloud.config.server.git.clone-on-start=true" + ); + + assertTrue( + containsAuthorityFailure(failure), + () -> "Blank authority must fail with the repository-authority message: " + failure + ); + } + + @Test + void unsetRepositoryUriFailsClosedWithoutInheritedEnvironmentAuthority() { + Exception failure = runExpectingFailure( + applicationWithoutInheritedConfigRepoUri(), + "--server.port=0", + "--eureka.client.enabled=false", + "--eureka.client.register-with-eureka=false", + "--eureka.client.fetch-registry=false" + ); + + assertTrue( + containsAuthorityFailure(failure), + () -> "Unset authority must fail with the repository-authority message: " + failure + ); + } + + @Test + void retiredDemoUriFailsBeforeCloneOnStartCanContactGit() { + Exception failure = runExpectingFailure( + applicationWithoutInheritedConfigRepoUri(), + "--server.port=0", + "--eureka.client.enabled=false", + "--eureka.client.register-with-eureka=false", + "--eureka.client.fetch-registry=false", + "--spring.cloud.config.server.git.uri=https://github.com/your-repo/config-repo.git", + "--spring.cloud.config.server.git.clone-on-start=true" + ); + + assertTrue( + containsAuthorityFailure(failure), + () -> "Demo URI must be rejected before JGit clone-on-start: " + failure + ); + } + + @Test + void nativeProfileCannotBeCombinedWithAnotherActiveProfile() { + Exception failure = runExpectingFailure( + applicationWithoutInheritedConfigRepoUri(), + "--server.port=0", + "--eureka.client.enabled=false", + "--eureka.client.register-with-eureka=false", + "--eureka.client.fetch-registry=false", + "--spring.profiles.active=native,default" + ); + + assertTrue( + messageChain(failure).contains("native profile must be the only active profile"), + () -> "Mixed native profile startup must fail closed: " + failure + ); + } + + 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 Exception runExpectingFailure(SpringApplication application, String... args) { + ConfigurableApplicationContext context = null; + try { + context = application.run(args); + 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 boolean containsAuthorityFailure(Throwable thrown) { + return messageChain(thrown).contains("CONFIG_REPO_URI must name a deployment-owned Git repository"); + } + + 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..4807674a --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java @@ -0,0 +1,68 @@ +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("${CONFIG_REPO_URI}") + ); + for (String demoRemote : new String[] { + "https://github.com/your-repo/config-repo.git", + "https://github.com/your-repo/config-repo", + "HTTPS://GITHUB.COM/YOUR-REPO/CONFIG-REPO.GIT", + "https://github.com/your-repo/config-repo/" + }) { + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository(demoRemote), + () -> "Retired demo repository must be rejected: " + demoRemote + ); + } + } + + @Test + void demoRepositoryMatchingDoesNotRejectUnrelatedPaths() { + assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "https://github.com/acme/your-repo/config-repo.git" + )); + assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "https://github.com/your-repo/config-repo-backup.git" + )); + } + + @Test + void acceptsExplicitHttpsAndFileAuthorities() { + assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "https://git.example.internal/config-repo.git" + )); + assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "file:/opt/reviewed-config-repo" + )); + } +} 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..f53ec818 --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java @@ -0,0 +1,77 @@ +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 static org.junit.jupiter.api.Assertions.assertEquals; +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")); + + long exactAuthorityTokens = applicationYaml.lines() + .map(String::trim) + .filter("uri: ${CONFIG_REPO_URI}"::equals) + .count(); + assertEquals( + 1L, + exactAuthorityTokens, + "Config Server must use exactly one CONFIG_REPO_URI token without a fallback" + ); + assertFalse( + applicationYaml.contains("${CONFIG_REPO_URI:"), + "CONFIG_REPO_URI must not acquire an implicit default value" + ); + assertFalse( + applicationYaml.contains("your-repo/config-repo.git"), + "A demo Git repository must never be a supported runtime fallback" + ); + } + + @Test + void environmentPostProcessorIsRegisteredBeforeConfigServerBeans() throws IOException { + Path factoriesPath = Path.of("src/main/resources/META-INF/spring.factories"); + assertTrue(Files.exists(factoriesPath), "Repository authority must run as an EnvironmentPostProcessor"); + String factories = Files.readString(factoriesPath); + assertTrue( + factories.contains("org.springframework.boot.env.EnvironmentPostProcessor=\\\n" + + "com.xtrmetl.config.ConfigServerRepositoryAuthorityEnvironmentPostProcessor"), + "EnvironmentPostProcessor registration must bind the repository-authority guard" + ); + } + + @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("ConfigServerRepositoryAuthorityEnvironmentPostProcessor")); + assertTrue(doctoring.contains("unset or blank `CONFIG_REPO_URI`")); + 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")); + assertTrue(doctoring.contains("n.d.")); + assertTrue(doctoring.contains("Retrieved August 28, 2026")); + } +} diff --git a/docs/doctoring/config-server-repository-authority.md b/docs/doctoring/config-server-repository-authority.md new file mode 100644 index 00000000..1f87de3c --- /dev/null +++ b/docs/doctoring/config-server-repository-authority.md @@ -0,0 +1,74 @@ +# Config Server repository authority doctoring + +**Capability status:** `active_pr` via replacement PR #327; 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 + +## 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 retired demo repository authority must fail closed during environment preparation, before Spring Cloud Config creates Git repository beans or `cloneOnStart` can contact a remote. + +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 reviewed 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 separate deployment secrets and must not be embedded in this URI. + +## 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. Local evidence showed that Spring Cloud Config can defer Git work when `cloneOnStart` is false, while a bean-level validator is not an ordering guarantee against JGit initialization when `cloneOnStart` is true. A YAML token change and an `InitializingBean` therefore cannot be the only controls. + +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; +- use `native` together with another active profile to bypass Git destination authority. + +The selected remedy keeps the exact `${CONFIG_REPO_URI}` token and registers `ConfigServerRepositoryAuthorityEnvironmentPostProcessor` through `META-INF/spring.factories`. It runs immediately after Spring Boot's `ConfigDataEnvironmentPostProcessor`, when ordinary config data has been loaded but before the application context creates Spring Cloud Config Git beans. It rejects blank or unresolved values, the retired `github.com/your-repo/config-repo[.git]` destination case-insensitively, and mixed `native` profile compositions. `ConfigServerRepositoryAuthorityValidator` remains defense in depth for non-native bean initialization. + +## Spring Boot and Spring Cloud Config contract + +Spring Boot 3.5 documents `EnvironmentPostProcessor` as the extension point for changing or validating the prepared `Environment` before the application context is refreshed, with registration through `META-INF/spring.factories`. `ConfigDataEnvironmentPostProcessor.ORDER` is public, so the repository-authority guard can run at `ORDER + 1`, after normal config data has been applied and before context refresh. + +Spring Cloud Config documents `spring.cloud.config.server.git.uri` as the Git environment-repository location. A deliberately configured `file:` repository can be appropriate for 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 cause Git access during startup. The authority guard therefore runs before Config Server Git beans rather than relying on a bean whose relative initialization order is not a security boundary. The repair does not itself enable cloning or redefine retry, timeout, readiness, recovery, or 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 a non-native profile is a deployment-configuration failure. The process must stop with a finite message that names `CONFIG_REPO_URI`. The retired demo GitHub path must also stop locally even when `cloneOnStart=true`, so a configuration defect cannot become an outbound Git request. + +Standalone `native` remains independently startable without `CONFIG_REPO_URI` for reviewed local filesystem fixtures. If `native` is combined with any other active profile, startup fails closed with a finite profile-composition message. Do not use `native` to bypass destination authority in a composed production topology. + +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. + +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. + +## Evidence and replacement lineage + +`ConfigServerRepositoryAuthorityLiveTest` isolates inherited `CONFIG_REPO_URI`, asserts finite authority failure for unset and blank values, exercises `clone-on-start=true` against the retired demo URI, and rejects mixed `native` profile startup. `ConfigServerRepositoryAuthorityTest` covers null, whitespace, unresolved placeholder, exact retired GitHub destinations with case and `.git` variants, non-matching paths, and explicit `https` / `file:` values. `ConfigServerRepositoryConfigurationTest` requires the exact YAML token with no default colon and the registered early environment guard. + +Canonical PRD, TRD, Architecture, Security, 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. (n.d.). *Externalized configuration*. In *Spring Boot reference documentation*. Retrieved August 28, 2026, from https://docs.spring.io/spring-boot/reference/features/external-config.html + +Spring Boot. (n.d.). *EnvironmentPostProcessor*. In *Spring Boot 3.5 API*. Retrieved August 28, 2026, from https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/env/EnvironmentPostProcessor.html + +Spring Boot. (n.d.). *ConfigDataEnvironmentPostProcessor*. In *Spring Boot 3.5 API*. Retrieved August 28, 2026, from https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessor.html + +Spring Cloud Config. (n.d.). *Git backend*. Retrieved August 28, 2026, from https://docs.spring.io/spring-cloud-config/reference/server/environment-repository/git-backend.html + +Spring Cloud Config. (n.d.). *Security*. Retrieved August 28, 2026, from https://docs.spring.io/spring-cloud-config/reference/server/security.html + +Spring Cloud Config. (2026, June 11). *Spring Cloud Config 5.0.4* [Software release]. GitHub. https://github.com/spring-cloud/spring-cloud-config/releases/tag/v5.0.4