From dd5a427d96bff24a925e5b29f6cc13d2912f54a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:51:15 +0900 Subject: [PATCH 1/8] test(config): reproduce implicit repository authority --- ...nfigServerRepositoryAuthorityLiveTest.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java 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..11930902 --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java @@ -0,0 +1,34 @@ +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the runnable Config Server never invents a remote Git repository authority. + */ +class ConfigServerRepositoryAuthorityLiveTest { + + @Test + void missingRepositoryAuthorityFailsClosedWithoutDemoRemote() throws IOException { + String applicationYaml = Files.readString(Path.of("src/main/resources/application.yml")); + + assertTrue( + applicationYaml.contains("uri: ${CONFIG_REPO_URI}"), + "Config Server must require an explicitly supplied CONFIG_REPO_URI" + ); + assertFalse( + applicationYaml.contains("your-repo/config-repo.git"), + "A demo remote must never become a runnable fallback" + ); + assertFalse( + applicationYaml.contains("CONFIG_REPO_URI:https://"), + "Missing repository authority must fail before implicit remote Git access" + ); + } +} From 43c404952afc66dd987bfaff8017f34744d27c9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:08:41 +0000 Subject: [PATCH 2/8] fix(config): reject blank Config Server repository authority Removing the demo Git default is not fail-closed: Spring Cloud Config 5.0.4 still starts with an unset or empty CONFIG_REPO_URI. Validate destination authority at default-profile startup and keep native fixtures independently startable. Co-authored-by: Seongho Bae --- AGENTS.md | 1 + ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + CLAUDE.md | 1 + .../ConfigServerRepositoryAuthority.java | 42 ++++++++ ...figServerRepositoryAuthorityValidator.java | 35 ++++++ .../src/main/resources/application.yml | 1 + ...nfigServerRepositoryAuthorityLiveTest.java | 102 ++++++++++++++---- .../ConfigServerRepositoryAuthorityTest.java | 52 +++++++++ ...erverRepositoryAuthorityValidatorTest.java | 26 +++++ ...nfigServerRepositoryConfigurationTest.java | 2 + .../config-server-repository-authority.md | 31 ++++-- 12 files changed, 263 insertions(+), 33 deletions(-) create mode 100644 config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthority.java create mode 100644 config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java create mode 100644 config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java create mode 100644 config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidatorTest.java 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..570da377 --- /dev/null +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthority.java @@ -0,0 +1,42 @@ +package com.xtrmetl.config; + +/** + * 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 default-profile startup, before any clone.

+ * + *

Operators: set {@code CONFIG_REPO_URI} to the reviewed Git URI, then start + * the default profile. Use the {@code native} profile only 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"; + + private static final String UNRESOLVED_PLACEHOLDER = "${CONFIG_REPO_URI"; + private static final String DEMO_REMOTE = "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, still a + * placeholder, or the retired demo remote + */ + public static void requireExplicitRepository(String repositoryUri) { + if (repositoryUri == null + || repositoryUri.isBlank() + || repositoryUri.contains(UNRESOLVED_PLACEHOLDER) + || repositoryUri.contains(DEMO_REMOTE)) { + throw new IllegalStateException(MISSING_AUTHORITY_MESSAGE); + } + } +} 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/application.yml b/config-server/src/main/resources/application.yml index 4a5143ec..3f2fa267 100644 --- a/config-server/src/main/resources/application.yml +++ b/config-server/src/main/resources/application.yml @@ -8,6 +8,7 @@ spring: config: server: git: + # Set CONFIG_REPO_URI to a reviewed Git URI before starting the default profile. uri: ${CONFIG_REPO_URI} eureka: diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java index 11930902..5ef6ca26 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java @@ -1,34 +1,94 @@ 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 java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; /** - * Verifies that the runnable Config Server never invents a remote Git repository authority. + * Starts the default Git-backed Config Server and proves blank repository + * authority cannot become a running process. + * + *

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

*/ class ConfigServerRepositoryAuthorityLiveTest { @Test - void missingRepositoryAuthorityFailsClosedWithoutDemoRemote() throws IOException { - String applicationYaml = Files.readString(Path.of("src/main/resources/application.yml")); - - assertTrue( - applicationYaml.contains("uri: ${CONFIG_REPO_URI}"), - "Config Server must require an explicitly supplied CONFIG_REPO_URI" - ); - assertFalse( - applicationYaml.contains("your-repo/config-repo.git"), - "A demo remote must never become a runnable fallback" - ); - assertFalse( - applicationYaml.contains("CONFIG_REPO_URI:https://"), - "Missing repository authority must fail before implicit remote Git access" - ); + void blankRepositoryUriFailsClosedBeforeGitAccess() { + SpringApplication application = new SpringApplication(ConfigServerApplication.class); + application.setWebApplicationType(WebApplicationType.SERVLET); + ConfigurableApplicationContext context = null; + try { + context = application.run( + "--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=false" + ); + fail("Blank CONFIG_REPO_URI must stop Config Server before Git access"); + } catch (Exception ex) { + assertTrue( + containsAuthorityFailure(ex), + () -> "Startup must name the missing repository authority, but failed with: " + ex + ); + } finally { + if (context != null) { + context.close(); + } + } + } + + @Test + void unsetRepositoryUriFailsClosedBeforeGitAccess() { + SpringApplication application = new SpringApplication(ConfigServerApplication.class); + application.setWebApplicationType(WebApplicationType.SERVLET); + ConfigurableApplicationContext context = null; + try { + context = application.run( + "--server.port=0", + "--eureka.client.enabled=false", + "--eureka.client.register-with-eureka=false", + "--eureka.client.fetch-registry=false" + ); + fail("Unset CONFIG_REPO_URI must stop Config Server before Git access"); + } catch (Exception ex) { + assertTrue( + containsAuthorityFailure(ex) || containsUnresolvedPlaceholder(ex), + () -> "Startup must fail closed without a repository URI, but failed with: " + 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 boolean containsUnresolvedPlaceholder(Throwable thrown) { + String messages = messageChain(thrown); + return messages.contains("Could not resolve placeholder 'CONFIG_REPO_URI'") + || messages.contains("Could not resolve placeholder 'spring.cloud.config.server.git.uri'"); + } + + 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..15616502 --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java @@ -0,0 +1,52 @@ +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}") + ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "https://github.com/your-repo/config-repo.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 index 882ecab8..9646fa38 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java @@ -48,6 +48,8 @@ void repositoryAuthorityHasSourceBackedDoctoring() throws IOException { assertTrue(doctoring.contains("CONFIG_REPO_URI")); assertTrue(doctoring.contains("cloneOnStart")); assertTrue(doctoring.contains("skipSslValidation")); + assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityValidator")); + 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 index b9b0f568..59511ce6 100644 --- a/docs/doctoring/config-server-repository-authority.md +++ b/docs/doctoring/config-server-repository-authority.md @@ -1,19 +1,23 @@ # Config Server repository authority doctoring -**Capability status:** `active_pr` via replacement PR #322; not `implemented_on_develop` until protected integration. -**Protected baseline assessed:** `develop@d6c6665163eabe1b5eca80556c6963bafd6b2625` +**Capability status:** `active_pr` via repair of replacement PR #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 ## 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` therefore uses only `${CONFIG_REPO_URI}` and has no example, demo, guessed, or implicit remote fallback. Missing repository authority must fail closed before remote Git access instead of silently selecting a destination that the operator did not approve. +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 for local fixtures that must not depend on a remote. Do not start the default profile with a blank 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; @@ -21,15 +25,16 @@ Rejected alternatives: - 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 repository credentials as proof that a destination is authorized; +- treat a YAML `String.contains` test as live fail-closed evidence. -The selected remedy changes only repository selection. Config Server HTTP authentication, Git credentials, trust material, retry and timeout policy, readiness, repository support status, and service-to-service identity remain separate controls. +The selected remedy keeps the explicit `${CONFIG_REPO_URI}` token and adds `ConfigServerRepositoryAuthorityValidator` on every non-`native` profile. That bean rejects blank, unresolved `${CONFIG_REPO_URI...}`, and the retired demo remote before JGit can run. 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. Replacement PR #322 deliberately does not enable it because that would change startup and network-availability semantics beyond the demonstrated fake-fallback defect. 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. +`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. @@ -37,11 +42,13 @@ Git credentials are deployment secrets, not repository authority. They must be e ## Failure, privacy, and operability semantics -With no fallback, a missing `CONFIG_REPO_URI` is a deployment-configuration failure. That failure is safer and more actionable than contacting an invented external repository and possibly serving misleading configuration. +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`. 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. 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 profile, not in a production fallback. +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 @@ -51,14 +58,16 @@ No central orchestrator, gateway, or sibling service acquires authority to rewri ## Evidence and replacement lineage -`ConfigServerRepositoryAuthorityLiveTest` on replacement PR #322 establishes current-base RED against the real deployable YAML. `ConfigServerRepositoryConfigurationTest` preserves the earlier behavior and doctoring contracts from PR #189. The replacement branch carries both tests plus the one-line production correction and this source-backed evidence; old PR #189 must not merge separately after exact unique-work preservation and current-head acceptance are proven. +`ConfigServerRepositoryAuthorityLiveTest` starts `ConfigServerApplication` on the default servlet profile and requires startup failure for unset and blank `CONFIG_REPO_URI`. `ConfigServerRepositoryAuthorityTest` and `ConfigServerRepositoryAuthorityValidatorTest` cover null, whitespace, unresolved placeholder, demo remote, and explicit `https` / `file:` values. `ConfigServerRepositoryConfigurationTest` keeps the YAML token and doctoring contract from PR #189 / #322. Old PR #189 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 Cloud Config. (2026). *Security (Spring Cloud Config 5.0.4)*. https://docs.spring.io/spring-cloud-config/reference/server/security.html - -Spring Cloud Config. (2026). *Config Server (Spring Cloud Config 5.0.4)*. https://docs.spring.io/spring-cloud-config/reference/server.html From 23ac292216c91999b9a94fb209b9c723aa25afa1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:19:21 +0000 Subject: [PATCH 3/8] fix(config): reject demo and templated Config Server authority before JGit Move the default-profile check to an EnvironmentPostProcessor so blank, retired-demo, and request-templated CONFIG_REPO_URI values fail closed before JGit afterPropertiesSet, including cloneOnStart=true. Keep the InitializingBean as defense in depth. native plus prod/production now requires xtrmetl.config.allow-native=true. Co-authored-by: Seongho Bae --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- .../ConfigServerRepositoryAuthority.java | 103 ++++++++++++++++-- ...toryAuthorityEnvironmentPostProcessor.java | 82 ++++++++++++++ ...figServerRepositoryAuthorityValidator.java | 9 +- .../main/resources/META-INF/spring.factories | 2 + ...ramework.boot.env.EnvironmentPostProcessor | 1 + ...AuthorityEnvironmentPostProcessorTest.java | 85 +++++++++++++++ ...nfigServerRepositoryAuthorityLiveTest.java | 96 ++++++++++------ .../ConfigServerRepositoryAuthorityTest.java | 62 ++++++++++- ...nfigServerRepositoryConfigurationTest.java | 17 ++- .../config-server-repository-authority.md | 10 +- 14 files changed, 418 insertions(+), 57 deletions(-) create mode 100644 config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java create mode 100644 config-server/src/main/resources/META-INF/spring.factories create mode 100644 config-server/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor create mode 100644 config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java diff --git a/AGENTS.md b/AGENTS.md index 12670aaa..2dd0836e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +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`. +- 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` plus `prod`/`production` requires `xtrmetl.config.allow-native=true`. 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 f2cfef3a..8e5558e3 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; 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. | +| 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 for local fixtures; `native` plus `prod`/`production` requires `xtrmetl.config.allow-native=true`. | | PostgreSQL | 5432 | TCP | Internal | | Kafka | 9092 | TCP | Internal | | Zipkin | 9412 | HTTP | Internal | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb5af7b..1f62f808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +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. +- 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 for local fixtures; `native` plus `prod`/`production` requires `xtrmetl.config.allow-native=true`. - 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 45eb7223..8083f02a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +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`. +- Config Server default Git profile requires `CONFIG_REPO_URI` and must fail closed on blank, unresolved, request-templated, or demo authority before JGit. `native` plus `prod`/`production` requires `xtrmetl.config.allow-native=true`. 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 index 570da377..a47a7552 100644 --- a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthority.java +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthority.java @@ -1,16 +1,24 @@ 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 during default-profile startup, before any clone.

+ * 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 for local fixtures - * that must not depend on a remote.

+ * that must not depend on a remote. Do not combine {@code native} with + * {@code prod} or {@code production} unless {@code xtrmetl.config.allow-native=true} + * is an approved fixture exception.

*/ public final class ConfigServerRepositoryAuthority { @@ -18,8 +26,18 @@ public final class ConfigServerRepositoryAuthority { "CONFIG_REPO_URI must name a deployment-owned Git repository; " + "blank, unresolved, or demo authority is rejected before remote Git access"; + 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 DEMO_REMOTE = "your-repo/config-repo.git"; + 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() { } @@ -28,15 +46,82 @@ 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 + * @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 - || repositoryUri.isBlank() - || repositoryUri.contains(UNRESOLVED_PLACEHOLDER) - || repositoryUri.contains(DEMO_REMOTE)) { + 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); + } + } + + /** + * 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..bf09ac6b --- /dev/null +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java @@ -0,0 +1,82 @@ +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.Environment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.Profiles; + +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 so + * local fixtures can start. Combining {@code native} with {@code prod} or + * {@code production} requires {@code xtrmetl.config.allow-native=true}.

+ */ +@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"; + static final String ALLOW_NATIVE_PROPERTY = "xtrmetl.config.allow-native"; + static final String NATIVE_PRODUCTION_MESSAGE = + "native profile cannot be combined with prod or production; " + + "set CONFIG_REPO_URI and start the default Git profile, " + + "or set xtrmetl.config.allow-native=true only for an approved fixture"; + + /** + * 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) { + requireCompatibleProfiles(environment); + if (environment.acceptsProfiles(Profiles.of("native"))) { + return; + } + String repositoryUri; + try { + repositoryUri = environment.getProperty(GIT_URI_PROPERTY); + } catch (IllegalArgumentException ex) { + throw new IllegalStateException(ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, ex); + } + 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) + )); + } + } + + /** + * Keeps {@code native} fixture-only unless an operator opts in. + * + * @param environment active Spring environment + */ + static void requireCompatibleProfiles(Environment environment) { + boolean nativeProfile = environment.acceptsProfiles(Profiles.of("native")); + boolean production = environment.acceptsProfiles(Profiles.of("prod", "production")); + boolean allowNative = Boolean.parseBoolean( + environment.getProperty(ALLOW_NATIVE_PROPERTY, "false") + ); + if (nativeProfile && production && !allowNative) { + throw new IllegalStateException(NATIVE_PRODUCTION_MESSAGE); + } + } +} diff --git a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java index 8c4708fe..9a7d4fb7 100644 --- a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java @@ -6,10 +6,13 @@ import org.springframework.stereotype.Component; /** - * Fails default-profile startup when Git repository authority is not explicit. + * Defense-in-depth fail-closed check after context refresh begins. * - *

The {@code native} profile is excluded so reviewed local fixtures can start - * without a remote. Do not use {@code native} as a production fallback.

+ *

{@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") 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/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor b/config-server/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor new file mode 100644 index 00000000..3dd9ddd1 --- /dev/null +++ b/config-server/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor @@ -0,0 +1 @@ +com.xtrmetl.config.ConfigServerRepositoryAuthorityEnvironmentPostProcessor 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..1b3353f4 --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java @@ -0,0 +1,85 @@ +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 nativeCombinedWithProductionFailsUnlessExplicitlyAllowed() { + MockEnvironment blocked = new MockEnvironment(); + blocked.setActiveProfiles("native", "prod"); + blocked.setProperty( + "spring.cloud.config.server.git.uri", + "https://git.example.internal/config-repo.git" + ); + assertEquals( + ConfigServerRepositoryAuthorityEnvironmentPostProcessor.NATIVE_PRODUCTION_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> processor.postProcessEnvironment(blocked, application) + ).getMessage() + ); + + MockEnvironment allowed = new MockEnvironment(); + allowed.setActiveProfiles("native", "production"); + allowed.setProperty("xtrmetl.config.allow-native", "true"); + assertDoesNotThrow(() -> processor.postProcessEnvironment(allowed, application)); + } +} diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java index 5ef6ca26..35dfdd66 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java @@ -10,8 +10,9 @@ import static org.junit.jupiter.api.Assertions.fail; /** - * Starts the default Git-backed Config Server and proves blank repository - * authority cannot become a running process. + * Starts the default Git-backed Config Server and proves blank or demo + * repository authority cannot become a running process, 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 for reviewed @@ -20,59 +21,86 @@ class ConfigServerRepositoryAuthorityLiveTest { @Test - void blankRepositoryUriFailsClosedBeforeGitAccess() { + void blankRepositoryUriFailsClosedWithAuthorityMessage() { + assertStartupFailure( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + "--spring.cloud.config.server.git.uri=", + "--spring.cloud.config.server.git.clone-on-start=true" + ); + } + + @Test + void demoRepositoryUriFailsClosedBeforeCloneOnStart() { + assertStartupFailure( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + "--spring.cloud.config.server.git.uri=https://github.com/your-repo/config-repo.git", + "--spring.cloud.config.server.git.clone-on-start=true" + ); + } + + @Test + void unsetRepositoryUriFailsClosedBeforeGitAccess() { SpringApplication application = new SpringApplication(ConfigServerApplication.class); application.setWebApplicationType(WebApplicationType.SERVLET); ConfigurableApplicationContext context = null; try { - context = application.run( - "--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=false" - ); - fail("Blank CONFIG_REPO_URI must stop Config Server before Git access"); + context = application.run(commonArgs()); + fail("Unset CONFIG_REPO_URI must stop Config Server before Git access"); } catch (Exception ex) { assertTrue( - containsAuthorityFailure(ex), - () -> "Startup must name the missing repository authority, but failed with: " + ex + containsAuthorityFailure(ex) || containsUnresolvedPlaceholder(ex), + () -> "Startup must fail closed without a repository URI, but failed with: " + ex ); } finally { - if (context != null) { - context.close(); - } + closeQuietly(context); } } @Test - void unsetRepositoryUriFailsClosedBeforeGitAccess() { + void nativeCombinedWithProductionFailsClosed() { + assertStartupFailure( + ConfigServerRepositoryAuthorityEnvironmentPostProcessor.NATIVE_PRODUCTION_MESSAGE, + "--spring.profiles.active=native,prod", + "--spring.cloud.config.server.native.search-locations=classpath:/", + "--spring.cloud.config.server.git.uri=https://git.example.internal/config-repo.git" + ); + } + + private static void assertStartupFailure(String expectedMessage, String... extraArgs) { SpringApplication application = new SpringApplication(ConfigServerApplication.class); application.setWebApplicationType(WebApplicationType.SERVLET); ConfigurableApplicationContext context = null; try { - context = application.run( - "--server.port=0", - "--eureka.client.enabled=false", - "--eureka.client.register-with-eureka=false", - "--eureka.client.fetch-registry=false" - ); - fail("Unset CONFIG_REPO_URI must stop Config Server before Git access"); + context = application.run(concat(commonArgs(), extraArgs)); + fail("Config Server must stop before Git access: " + expectedMessage); } catch (Exception ex) { assertTrue( - containsAuthorityFailure(ex) || containsUnresolvedPlaceholder(ex), - () -> "Startup must fail closed without a repository URI, but failed with: " + ex + messageChain(ex).contains(expectedMessage), + () -> "Startup must name the missing repository authority, but failed with: " + ex ); } finally { - if (context != null) { - context.close(); - } + closeQuietly(context); } } + 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("CONFIG_REPO_URI must name a deployment-owned Git repository"); + return messageChain(thrown).contains(ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE); } private static boolean containsUnresolvedPlaceholder(Throwable thrown) { @@ -91,4 +119,10 @@ private static String messageChain(Throwable thrown) { } return messages.toString(); } + + private static void closeQuietly(ConfigurableApplicationContext context) { + if (context != null) { + context.close(); + } + } } diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java index 15616502..417cf46c 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java @@ -28,25 +28,85 @@ void rejectsMissingBlankUnresolvedAndDemoRemotes() { 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" + ) + ); + } + + @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 acceptsExplicitHttpsAndFileAuthorities() { + 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/ConfigServerRepositoryConfigurationTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java index 9646fa38..227189bb 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java @@ -5,6 +5,7 @@ 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; @@ -19,16 +20,18 @@ void configRepositoryMustBeExplicitAndHaveNoDemoFallback() throws IOException { String applicationYaml = Files.readString(Path.of("src/main/resources/application.yml")); assertTrue( - applicationYaml.contains("uri: ${CONFIG_REPO_URI}"), - "Config Server must require an operator-supplied CONFIG_REPO_URI" + 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("your-repo/config-repo.git"), - "A demo Git repository must never be a supported runtime fallback" + applicationYaml.contains("${CONFIG_REPO_URI:"), + "Missing repository authority must not carry a YAML default colon" ); assertFalse( - applicationYaml.contains("CONFIG_REPO_URI:https://"), - "Missing repository authority must fail closed before remote Git access" + applicationYaml.contains("your-repo/config-repo.git"), + "A demo Git repository must never be a supported runtime fallback" ); } @@ -49,6 +52,8 @@ void repositoryAuthorityHasSourceBackedDoctoring() throws IOException { assertTrue(doctoring.contains("cloneOnStart")); assertTrue(doctoring.contains("skipSslValidation")); assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityValidator")); + assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityEnvironmentPostProcessor")); + assertTrue(doctoring.contains("xtrmetl.config.allow-native")); 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")); diff --git a/docs/doctoring/config-server-repository-authority.md b/docs/doctoring/config-server-repository-authority.md index 59511ce6..594c1382 100644 --- a/docs/doctoring/config-server-repository-authority.md +++ b/docs/doctoring/config-server-repository-authority.md @@ -28,7 +28,7 @@ Rejected alternatives: - 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 adds `ConfigServerRepositoryAuthorityValidator` on every non-`native` profile. That bean rejects blank, unresolved `${CONFIG_REPO_URI...}`, and the retired demo remote before JGit can run. Config Server HTTP authentication, Git credentials, trust material, retry and timeout policy, readiness, repository support status, and service-to-service identity remain separate controls. +The selected remedy keeps the explicit `${CONFIG_REPO_URI}` token and registers `ConfigServerRepositoryAuthorityEnvironmentPostProcessor` after config-data load. That 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 or `git@`) before JGit `afterPropertiesSet`. `ConfigServerRepositoryAuthorityValidator` remains as defense in depth on every non-`native` profile. Combining `native` with `prod` or `production` fails unless `xtrmetl.config.allow-native=true`. 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 @@ -44,7 +44,7 @@ Git credentials are deployment secrets, not repository authority. They must be e 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`. 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. +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 `native,prod` or `native,production` mix is a configuration failure unless an operator sets `xtrmetl.config.allow-native=true` for an approved fixture. 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. @@ -58,7 +58,7 @@ No central orchestrator, gateway, or sibling service acquires authority to rewri ## Evidence and replacement lineage -`ConfigServerRepositoryAuthorityLiveTest` starts `ConfigServerApplication` on the default servlet profile and requires startup failure for unset and blank `CONFIG_REPO_URI`. `ConfigServerRepositoryAuthorityTest` and `ConfigServerRepositoryAuthorityValidatorTest` cover null, whitespace, unresolved placeholder, demo remote, and explicit `https` / `file:` values. `ConfigServerRepositoryConfigurationTest` keeps the YAML token and doctoring contract from PR #189 / #322. Old PR #189 must not merge separately after this unique work is accepted. +`ConfigServerRepositoryAuthorityLiveTest` starts `ConfigServerApplication` on the default servlet profile. 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 message or Spring's unresolved-placeholder failure. `native,prod` must fail with the native/production message. `ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest` covers blank, demo, trim, native skip, and native+prod opt-in. `ConfigServerRepositoryAuthorityTest` covers null, ASCII blank, NBSP/ZWSP, `${CONFIG_REPO_URI:}` defaults, case and no-`.git` demo variants, `git@` demo, request templates, padded `https`, `ssh`, `file:`, and a non-demo nested path. `ConfigServerRepositoryConfigurationTest` pins the exact YAML token with no default colon. 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. @@ -70,4 +70,8 @@ Spring Cloud Config. (2026). *Config Server (Spring Cloud Config 5.0.4)*. https: 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). *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 From f9df0cf76d06b465015bbbb00261389eda41d0cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:20:30 +0000 Subject: [PATCH 4/8] docs(config): add Config Server authority operator decision flow Show the next action for native fixtures versus a reviewed CONFIG_REPO_URI so operators can start or stop without reading the full doctoring note. Co-authored-by: Seongho Bae --- .../config-server-repository-authority.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/config-server-repository-authority.md b/docs/doctoring/config-server-repository-authority.md index 594c1382..b32ce032 100644 --- a/docs/doctoring/config-server-repository-authority.md +++ b/docs/doctoring/config-server-repository-authority.md @@ -1,9 +1,22 @@ # Config Server repository authority doctoring -**Capability status:** `active_pr` via repair of replacement PR #322; not `implemented_on_develop` until protected integration. +**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| prod{prod or production also active?} + prod -->|yes| allow{xtrmetl.config.allow-native=true?} + allow -->|no| failNative[Stop. Export CONFIG_REPO_URI and start the default Git profile, or set allow-native only for an approved fixture] + allow -->|yes| fixtures[Continue native fixtures] + prod -->|no| 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. From 7a85d09121f5b6bb5595e55c262e4ff707affc79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:06:03 +0900 Subject: [PATCH 5/8] fix(config): inherit fail-closed evidence onto Config Server authority Port #327 live isolation and native-only composition into #328 without dropping demo, template, Unicode, or SCP matching. Unset CONFIG_REPO_URI now fails with the finite authority message after systemEnvironment is removed, and mixed native profiles cannot skip Git destination checks. Verified: ./mvnw -B -pl config-server -am test (Temurin 25, 19 tests). --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- .../ConfigServerRepositoryAuthority.java | 35 +++++- ...toryAuthorityEnvironmentPostProcessor.java | 38 ++----- ...AuthorityEnvironmentPostProcessorTest.java | 35 ++++-- ...nfigServerRepositoryAuthorityLiveTest.java | 100 ++++++++---------- .../ConfigServerRepositoryAuthorityTest.java | 32 ++++++ ...nfigServerRepositoryConfigurationTest.java | 17 ++- .../config-server-repository-authority.md | 18 ++-- 11 files changed, 171 insertions(+), 112 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2dd0836e..fae6099b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +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` plus `prod`/`production` requires `xtrmetl.config.allow-native=true`. See `docs/doctoring/config-server-repository-authority.md`. +- 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 8e5558e3..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; 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 for local fixtures; `native` plus `prod`/`production` requires `xtrmetl.config.allow-native=true`. | +| 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 1f62f808..f3fece89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +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 for local fixtures; `native` plus `prod`/`production` requires `xtrmetl.config.allow-native=true`. +- 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 8083f02a..df0c4f7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +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` plus `prod`/`production` requires `xtrmetl.config.allow-native=true`. See `docs/doctoring/config-server-repository-authority.md`. +- 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 index a47a7552..9cff65be 100644 --- a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthority.java +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthority.java @@ -15,10 +15,8 @@ * 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 for local fixtures - * that must not depend on a remote. Do not combine {@code native} with - * {@code prod} or {@code production} unless {@code xtrmetl.config.allow-native=true} - * is an approved fixture exception.

+ * 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 { @@ -26,6 +24,9 @@ public final class ConfigServerRepositoryAuthority { "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"; @@ -65,6 +66,32 @@ public static void requireExplicitRepository(String repositoryUri) { } } + /** + * 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. * diff --git a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java index bf09ac6b..5f259848 100644 --- a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java @@ -5,9 +5,7 @@ import org.springframework.boot.env.EnvironmentPostProcessor; import org.springframework.core.annotation.Order; import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.Environment; import org.springframework.core.env.MapPropertySource; -import org.springframework.core.env.Profiles; import java.util.Map; @@ -21,20 +19,14 @@ * 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 so - * local fixtures can start. Combining {@code native} with {@code prod} or - * {@code production} requires {@code xtrmetl.config.allow-native=true}.

+ * 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"; - static final String ALLOW_NATIVE_PROPERTY = "xtrmetl.config.allow-native"; - static final String NATIVE_PRODUCTION_MESSAGE = - "native profile cannot be combined with prod or production; " - + "set CONFIG_REPO_URI and start the default Git profile, " - + "or set xtrmetl.config.allow-native=true only for an approved fixture"; /** * Fails closed on incompatible profiles or missing Git authority. @@ -44,15 +36,17 @@ public class ConfigServerRepositoryAuthorityEnvironmentPostProcessor implements */ @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { - requireCompatibleProfiles(environment); - if (environment.acceptsProfiles(Profiles.of("native"))) { + if (ConfigServerRepositoryAuthority.requireSafeProfileComposition(environment.getActiveProfiles())) { return; } String repositoryUri; try { repositoryUri = environment.getProperty(GIT_URI_PROPERTY); - } catch (IllegalArgumentException ex) { - throw new IllegalStateException(ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, ex); + } catch (IllegalArgumentException unresolvedPlaceholder) { + throw new IllegalStateException( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + unresolvedPlaceholder + ); } ConfigServerRepositoryAuthority.requireExplicitRepository(repositoryUri); String trimmed = ConfigServerRepositoryAuthority.trimAuthority(repositoryUri); @@ -63,20 +57,4 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp )); } } - - /** - * Keeps {@code native} fixture-only unless an operator opts in. - * - * @param environment active Spring environment - */ - static void requireCompatibleProfiles(Environment environment) { - boolean nativeProfile = environment.acceptsProfiles(Profiles.of("native")); - boolean production = environment.acceptsProfiles(Profiles.of("prod", "production")); - boolean allowNative = Boolean.parseBoolean( - environment.getProperty(ALLOW_NATIVE_PROPERTY, "false") - ); - if (nativeProfile && production && !allowNative) { - throw new IllegalStateException(NATIVE_PRODUCTION_MESSAGE); - } - } } diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java index 1b3353f4..949def00 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java @@ -62,24 +62,39 @@ void nativeProfileSkipsGitUriCheck() { } @Test - void nativeCombinedWithProductionFailsUnlessExplicitlyAllowed() { - MockEnvironment blocked = new MockEnvironment(); - blocked.setActiveProfiles("native", "prod"); - blocked.setProperty( + 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( - ConfigServerRepositoryAuthorityEnvironmentPostProcessor.NATIVE_PRODUCTION_MESSAGE, + ConfigServerRepositoryAuthority.MIXED_NATIVE_PROFILE_MESSAGE, assertThrows( IllegalStateException.class, - () -> processor.postProcessEnvironment(blocked, application) + () -> processor.postProcessEnvironment(mixed, application) ).getMessage() ); + } - MockEnvironment allowed = new MockEnvironment(); - allowed.setActiveProfiles("native", "production"); - allowed.setProperty("xtrmetl.config.allow-native", "true"); - assertDoesNotThrow(() -> processor.postProcessEnvironment(allowed, application)); + @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 index 35dfdd66..e863dad9 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java @@ -4,85 +4,91 @@ 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 default Git-backed Config Server and proves blank or demo - * repository authority cannot become a running process, including when - * {@code cloneOnStart} is true. + * 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 for reviewed - * local fixtures that do not need a remote.

+ * 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() { - assertStartupFailure( - ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, - "--spring.cloud.config.server.git.uri=", - "--spring.cloud.config.server.git.clone-on-start=true" + 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() { - assertStartupFailure( - ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, - "--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(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 unsetRepositoryUriFailsClosedBeforeGitAccess() { - SpringApplication application = new SpringApplication(ConfigServerApplication.class); - application.setWebApplicationType(WebApplicationType.SERVLET); - ConfigurableApplicationContext context = null; - try { - context = application.run(commonArgs()); - fail("Unset CONFIG_REPO_URI must stop Config Server before Git access"); - } catch (Exception ex) { - assertTrue( - containsAuthorityFailure(ex) || containsUnresolvedPlaceholder(ex), - () -> "Startup must fail closed without a repository URI, but failed with: " + ex - ); - } finally { - closeQuietly(context); - } + void unsetRepositoryUriFailsClosedWithoutInheritedEnvironmentAuthority() { + assertTrue( + containsAuthorityFailure(runExpectingFailure()), + "Unset CONFIG_REPO_URI must fail with the repository-authority message" + ); } @Test - void nativeCombinedWithProductionFailsClosed() { - assertStartupFailure( - ConfigServerRepositoryAuthorityEnvironmentPostProcessor.NATIVE_PRODUCTION_MESSAGE, - "--spring.profiles.active=native,prod", + 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 void assertStartupFailure(String expectedMessage, String... extraArgs) { - SpringApplication application = new SpringApplication(ConfigServerApplication.class); - application.setWebApplicationType(WebApplicationType.SERVLET); + private static Exception runExpectingFailure(String... extraArgs) { + SpringApplication application = applicationWithoutInheritedConfigRepoUri(); ConfigurableApplicationContext context = null; try { context = application.run(concat(commonArgs(), extraArgs)); - fail("Config Server must stop before Git access: " + expectedMessage); + fail("Config Server startup was expected to fail closed"); + throw new AssertionError("unreachable"); } catch (Exception ex) { - assertTrue( - messageChain(ex).contains(expectedMessage), - () -> "Startup must name the missing repository authority, but failed with: " + ex - ); + return ex; } finally { - closeQuietly(context); + 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", @@ -103,12 +109,6 @@ private static boolean containsAuthorityFailure(Throwable thrown) { return messageChain(thrown).contains(ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE); } - private static boolean containsUnresolvedPlaceholder(Throwable thrown) { - String messages = messageChain(thrown); - return messages.contains("Could not resolve placeholder 'CONFIG_REPO_URI'") - || messages.contains("Could not resolve placeholder 'spring.cloud.config.server.git.uri'"); - } - private static String messageChain(Throwable thrown) { assertNotNull(thrown); StringBuilder messages = new StringBuilder(); @@ -119,10 +119,4 @@ private static String messageChain(Throwable thrown) { } return messages.toString(); } - - private static void closeQuietly(ConfigurableApplicationContext context) { - if (context != null) { - context.close(); - } - } } diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java index 417cf46c..d21fd93e 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java @@ -70,6 +70,38 @@ void rejectsMissingBlankUnresolvedAndDemoRemotes() { "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 diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java index 227189bb..57bf4e2b 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java @@ -35,6 +35,19 @@ void configRepositoryMustBeExplicitAndHaveNoDemoFallback() throws IOException { ); } + @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 repositoryAuthorityHasSourceBackedDoctoring() throws IOException { Path doctoringPath = Path.of( @@ -53,7 +66,9 @@ void repositoryAuthorityHasSourceBackedDoctoring() throws IOException { assertTrue(doctoring.contains("skipSslValidation")); assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityValidator")); assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityEnvironmentPostProcessor")); - assertTrue(doctoring.contains("xtrmetl.config.allow-native")); + 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")); diff --git a/docs/doctoring/config-server-repository-authority.md b/docs/doctoring/config-server-repository-authority.md index b32ce032..1a89547a 100644 --- a/docs/doctoring/config-server-repository-authority.md +++ b/docs/doctoring/config-server-repository-authority.md @@ -7,11 +7,9 @@ ```mermaid flowchart TD start[Start Config Server] --> native{native profile active?} - native -->|yes| prod{prod or production also active?} - prod -->|yes| allow{xtrmetl.config.allow-native=true?} - allow -->|no| failNative[Stop. Export CONFIG_REPO_URI and start the default Git profile, or set allow-native only for an approved fixture] - allow -->|yes| fixtures[Continue native fixtures] - prod -->|no| fixtures + 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] @@ -23,7 +21,7 @@ mightyETL's independently runnable Config Server Git backend must have explicit 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 for local fixtures that must not depend on a remote. Do not start the default profile with a blank secret. +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 @@ -41,7 +39,7 @@ Rejected alternatives: - 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` after config-data load. That 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 or `git@`) before JGit `afterPropertiesSet`. `ConfigServerRepositoryAuthorityValidator` remains as defense in depth on every non-`native` profile. Combining `native` with `prod` or `production` fails unless `xtrmetl.config.allow-native=true`. Config Server HTTP authentication, Git credentials, trust material, retry and timeout policy, readiness, repository support status, and service-to-service identity remain separate controls. +The selected remedy keeps the explicit `${CONFIG_REPO_URI}` token and registers `ConfigServerRepositoryAuthorityEnvironmentPostProcessor` in `META-INF/spring.factories` after config-data load. Boot 3.5.16 discovers that processor only through `spring.factories`; the imports-style file is retained for later Boot 4 discovery and is not the Boot 3.5 control. 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 @@ -55,9 +53,9 @@ Git credentials are deployment secrets, not repository authority. They must be e ## 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`. That failure is safer and more actionable than contacting an invented external repository or serving misleading configuration from an empty URI. +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 `native,prod` or `native,production` mix is a configuration failure unless an operator sets `xtrmetl.config.allow-native=true` for an approved fixture. +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. @@ -71,7 +69,7 @@ No central orchestrator, gateway, or sibling service acquires authority to rewri ## Evidence and replacement lineage -`ConfigServerRepositoryAuthorityLiveTest` starts `ConfigServerApplication` on the default servlet profile. 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 message or Spring's unresolved-placeholder failure. `native,prod` must fail with the native/production message. `ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest` covers blank, demo, trim, native skip, and native+prod opt-in. `ConfigServerRepositoryAuthorityTest` covers null, ASCII blank, NBSP/ZWSP, `${CONFIG_REPO_URI:}` defaults, case and no-`.git` demo variants, `git@` demo, request templates, padded `https`, `ssh`, `file:`, and a non-demo nested path. `ConfigServerRepositoryConfigurationTest` pins the exact YAML token with no default colon. Old PR #189 / #322 / #327 must not merge separately after this unique work is accepted. +`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 and Boot 3.5 `META-INF/spring.factories` discovery. 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. From b1fdb4a871fb1dbeb9cfb9ade44143e199d90104 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:15:15 +0900 Subject: [PATCH 6/8] test(config): reject inert environment processor registration --- .../ConfigServerRepositoryConfigurationTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java index 57bf4e2b..e6a04c09 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java @@ -48,6 +48,16 @@ void boot35DiscoversTheProcessorThroughSpringFactories() throws IOException { ); } + @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( From e295de44c3b26087d007c70db2d682c48b0ae9cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:15:28 +0900 Subject: [PATCH 7/8] fix(config): remove inert environment processor registration --- .../spring/org.springframework.boot.env.EnvironmentPostProcessor | 1 - 1 file changed, 1 deletion(-) delete mode 100644 config-server/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor diff --git a/config-server/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor b/config-server/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor deleted file mode 100644 index 3dd9ddd1..00000000 --- a/config-server/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor +++ /dev/null @@ -1 +0,0 @@ -com.xtrmetl.config.ConfigServerRepositoryAuthorityEnvironmentPostProcessor From 4785b03d335e6536f30743da57b35ed2dace8b4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:17:13 +0900 Subject: [PATCH 8/8] docs(config): correct EnvironmentPostProcessor registration authority --- docs/doctoring/config-server-repository-authority.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/config-server-repository-authority.md b/docs/doctoring/config-server-repository-authority.md index 1a89547a..2141e05a 100644 --- a/docs/doctoring/config-server-repository-authority.md +++ b/docs/doctoring/config-server-repository-authority.md @@ -39,7 +39,7 @@ Rejected alternatives: - 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. Boot 3.5.16 discovers that processor only through `spring.factories`; the imports-style file is retained for later Boot 4 discovery and is not the Boot 3.5 control. 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. +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 @@ -69,7 +69,7 @@ No central orchestrator, gateway, or sibling service acquires authority to rewri ## 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 and Boot 3.5 `META-INF/spring.factories` discovery. Old PR #189 / #322 / #327 must not merge separately after this unique work is accepted. +`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. @@ -83,6 +83,8 @@ Spring Cloud Config. (2026). *Git backend (Spring Cloud Config 5.0.4)*. https:// 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