This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new d46d56d9ad Scope stored marketplace credentials to their own
repository, fixes #8393 (#8398)
d46d56d9ad is described below
commit d46d56d9ad67fe3a6a889469149202d89885acf2
Author: Bart Maertens <[email protected]>
AuthorDate: Wed Sep 16 10:41:14 2026 +0200
Scope stored marketplace credentials to their own repository, fixes #8393
(#8398)
A project's install spec can declare its own plugin repository. When the
declaration omitted a username or password, EnvironmentApplier filled them
in
from the globally configured repository while taking the URL from the
project,
so enabling a project that named an unconfigured repository sent the
operator's
credentials to it.
Configured credentials are now only reused when the spec URL points at the
same
scheme, host and port as a configured repository, so a second path on the
same
repository manager keeps working. A spec repository with no configured
counterpart also opts out of the global HOP_MARKETPLACE_USERNAME/_PASSWORD
pair,
which reaches a project-chosen host by the same route; the repository-scoped
HOP_MARKETPLACE_<ID>_* variables still apply.
---
.../ROOT/pages/hop-tools/hop-marketplace.adoc | 11 +-
plugins/misc/marketplace/README.md | 8 +
.../marketplace/config/MarketplaceRepository.java | 74 ++++++++-
.../hop/marketplace/env/EnvironmentApplier.java | 72 +++++++--
.../marketplace/config/RepositoryOriginTest.java | 129 ++++++++++++++++
.../marketplace/env/EnvironmentApplierTest.java | 109 +++++++++++++
...stallSpecRepositoryCredentialScopeHttpTest.java | 172 +++++++++++++++++++++
7 files changed, 556 insertions(+), 19 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
index 0af25f8abe..ebb13f8e77 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
@@ -435,7 +435,7 @@ dependencies:
| `repositories`
| No
-| List of Maven base URLs used when *applying* this file. Order: first =
primary, rest = fallbacks. Each item: `id`, `url`, optional `username` /
`password`.
+| List of Maven base URLs used when *applying* this file. Order: first =
primary, rest = fallbacks. Each item: `id`, `url`, optional `username` /
`password`. A repository here only inherits configured credentials when it
points at a repository manager that is already configured — see
<<marketplace-spec-repository-credentials>>.
| `plugins`
| No
@@ -491,6 +491,15 @@ Use the repository-scoped variables when more than one
private repository is con
Username and password are resolved separately, so the two can come from
different sources: a repository entry that sets only `username` is combined
with `HOP_MARKETPLACE_PASSWORD` from the environment. Such a mixed pair counts
as deliberately configured — the anonymous retry described below applies only
when the repository entry supplies neither field.
+[[marketplace-spec-repository-credentials]]
+=== Repositories declared by an install spec
+
+An install spec carried by a project can declare its own `repositories`.
Because that URL comes from the project rather than from the operator, the
configured credentials are only reused for it when the project points at the
*same* repository manager they belong to — the same scheme, host and port as a
repository in `hop-config`. The path may differ, so a second repository on the
same Nexus or Artifactory keeps working.
+
+A spec repository on any other host gets only what the spec itself declares.
Neither the `username` / `password` stored on a configured repository nor the
global `HOP_MARKETPLACE_USERNAME` / `HOP_MARKETPLACE_PASSWORD` pair is sent to
it, so enabling a project cannot make Hop hand the operator's repository
credentials to an address of the project's choosing. The log says so at basic
level when credentials are withheld this way.
+
+To give such a repository credentials, set `username` / `password` on its
entry in the spec (prefer a variable over a literal secret), or set the
repository-scoped `HOP_MARKETPLACE_<ID>_USERNAME` / `_PASSWORD` variables for
the `id` the spec uses — those name one repository, so they still apply.
+
=== Authentication type
`authType` on a repository chooses how those credentials are sent:
diff --git a/plugins/misc/marketplace/README.md
b/plugins/misc/marketplace/README.md
index c7de8614ec..79b08d2209 100644
--- a/plugins/misc/marketplace/README.md
+++ b/plugins/misc/marketplace/README.md
@@ -199,6 +199,14 @@ combined with `HOP_MARKETPLACE_PASSWORD` from the
environment. A mixed pair
counts as deliberate — the anonymous retry below applies only when the entry
supplies neither field.
+Repositories declared by a hop-env install spec are scoped: because the URL
+comes from the project, the configured credentials are only reused when the
+spec points at the same scheme, host and port as a repository in
+`hop-config.json` (the path may differ). A spec repository on any other host
+gets only what the spec declares — neither a stored `username` / `password` nor
+the global `HOP_MARKETPLACE_*` pair reaches it. Give it credentials with its
own
+entry fields or the scoped `HOP_MARKETPLACE_<ID>_*` variables.
+
A `password` on a repository entry is stored obfuscated (`Encrypted 2be98afc…`,
Hop's two-way password encoder) in `hop-config.json`, in exported repository
definitions and in environment files. Older clear-text configurations are read
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
index 604f42b764..9eddec0fe9 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
@@ -17,8 +17,11 @@
package org.apache.hop.marketplace.config;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -170,6 +173,18 @@ public class MarketplaceRepository {
/** Optional human description (documentation / export). */
private String description;
+ /**
+ * When false, the global {@code HOP_MARKETPLACE_USERNAME} / {@code
HOP_MARKETPLACE_PASSWORD}
+ * variables do not apply to this repository; only credentials set on the
entry itself or in the
+ * repository-scoped {@code HOP_MARKETPLACE_<ID>_*} variables are used.
+ *
+ * <p>Set false for a repository an install spec introduced that is not one
the operator
+ * configured: the global pair is meant for the operator's own repositories,
and a project must
+ * not be able to have it sent to a host of the project's choosing. Not
persisted — it follows
+ * from where the repository came from, not from configuration.
+ */
+ @JsonIgnore private boolean globalEnvironmentCredentials = true;
+
public MarketplaceRepository() {
// Jackson
}
@@ -213,6 +228,54 @@ public class MarketplaceRepository {
return url.endsWith("/") ? url : url + "/";
}
+ /**
+ * Scheme, host and port of {@link #normalizedUrl()} — the identity a
credential belongs to —
+ * lower-cased and with the default port of the scheme made explicit, or
{@code null} when the URL
+ * is not an absolute URL with a host. The path is deliberately left out:
several repositories on
+ * one repository manager share one set of credentials.
+ */
+ public String origin() {
+ return originOf(normalizedUrl());
+ }
+
+ /** See {@link #origin()}. Returns {@code null} for a blank, relative or
unparseable URL. */
+ public static String originOf(String repositoryUrl) {
+ if (StringUtils.isBlank(repositoryUrl)) {
+ return null;
+ }
+ try {
+ URI uri = new URI(repositoryUrl.trim());
+ String scheme = uri.getScheme();
+ String host = uri.getHost();
+ if (StringUtils.isAnyBlank(scheme, host)) {
+ return null;
+ }
+ scheme = scheme.toLowerCase(Locale.ROOT);
+ int port = uri.getPort() < 0 ? defaultPort(scheme) : uri.getPort();
+ return scheme + "://" + host.toLowerCase(Locale.ROOT) + (port < 0 ? "" :
":" + port);
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ }
+
+ private static int defaultPort(String scheme) {
+ return switch (scheme) {
+ case "https" -> 443;
+ case "http" -> 80;
+ default -> -1;
+ };
+ }
+
+ /**
+ * True when {@code otherUrl} points at the same scheme, host and port as
this repository, which
+ * is the condition for this repository's credentials to be safe to use for
it. An unparseable URL
+ * on either side is never a match: no origin means no credentials rather
than a guess.
+ */
+ public boolean sameOriginAs(String otherUrl) {
+ String mine = origin();
+ return mine != null && mine.equals(originOf(otherUrl));
+ }
+
/**
* Environment lookup, replaceable in tests. Package-private on purpose:
credential resolution is
* otherwise untestable, and it is the part most likely to go subtly wrong.
@@ -238,6 +301,11 @@ public class MarketplaceRepository {
return id.trim().toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "_");
}
+ /** The global {@code HOP_MARKETPLACE_*} pair, unless this repository opted
out of it. */
+ private String globalEnv(String name) {
+ return globalEnvironmentCredentials ? env(name) : null;
+ }
+
private String scopedEnv(String suffix) {
String prefix = environmentIdPrefix();
return StringUtils.isBlank(prefix) ? null : env("HOP_MARKETPLACE_" +
prefix + "_" + suffix);
@@ -262,8 +330,8 @@ public class MarketplaceRepository {
return firstNonBlank(
scopedEnv("USERNAME"),
scopedEnv("USER"),
- env("HOP_MARKETPLACE_USERNAME"),
- env("HOP_MARKETPLACE_USER"));
+ globalEnv("HOP_MARKETPLACE_USERNAME"),
+ globalEnv("HOP_MARKETPLACE_USER"));
}
public String effectivePassword() {
@@ -271,7 +339,7 @@ public class MarketplaceRepository {
return MarketplaceSecrets.resolve(password);
}
return firstNonBlank(
- scopedEnv("PASSWORD"), scopedEnv("TOKEN"),
env("HOP_MARKETPLACE_PASSWORD"));
+ scopedEnv("PASSWORD"), scopedEnv("TOKEN"),
globalEnv("HOP_MARKETPLACE_PASSWORD"));
}
/**
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/EnvironmentApplier.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/EnvironmentApplier.java
index 7320026067..d1a0bb7324 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/EnvironmentApplier.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/EnvironmentApplier.java
@@ -220,7 +220,8 @@ public class EnvironmentApplier {
}
}
- private MarketplaceConfig configFromEnv(HopInstallSpec env) {
+ /** Package-private so the credential scoping below can be asserted without
a live install. */
+ MarketplaceConfig configFromEnv(HopInstallSpec env) {
MarketplaceConfig config = new MarketplaceConfig();
config.setEnabled(baseConfig.isEnabled());
config.setGroupId(baseConfig.getGroupId());
@@ -229,24 +230,11 @@ public class EnvironmentApplier {
? env.getHopVersion()
: MarketplaceCommand.resolveDefaultVersion(baseConfig));
config.getRepositories().clear();
- MarketplaceRepository baseRepo = baseConfig.primaryRepository();
if (env.getRepositories() != null && !env.getRepositories().isEmpty()) {
boolean first = true;
for (HopInstallSpec.RepositoryRef ref : env.getRepositories()) {
if (StringUtils.isNotBlank(ref.getUrl())) {
- MarketplaceRepository repo =
- new MarketplaceRepository(
- StringUtils.defaultIfBlank(ref.getId(), "spec"),
- ref.getUrl(),
- StringUtils.isNotBlank(ref.getUsername())
- ? ref.getUsername()
- : baseRepo.getUsername(),
- StringUtils.isNotBlank(ref.getPassword())
- ? ref.getPassword()
- : baseRepo.getPassword());
- repo.setPrimary(first);
- repo.setEnabled(true);
- config.getRepositories().add(repo);
+ config.getRepositories().add(repositoryFromRef(ref, first));
first = false;
}
}
@@ -261,6 +249,60 @@ public class EnvironmentApplier {
return config;
}
+ /**
+ * Turn a repository the install spec declares into a marketplace
repository. The URL is the
+ * project's, so the configured credentials are only reused when the project
points at the same
+ * repository they belong to — same scheme, host and port. A project naming
a host the operator
+ * never configured gets what the project itself declared, or nothing.
+ */
+ private MarketplaceRepository repositoryFromRef(HopInstallSpec.RepositoryRef
ref, boolean first) {
+ MarketplaceRepository source = configuredCredentialSource(ref.getUrl());
+ MarketplaceRepository repo =
+ new MarketplaceRepository(
+ StringUtils.defaultIfBlank(ref.getId(), "spec"),
+ ref.getUrl(),
+ StringUtils.isNotBlank(ref.getUsername())
+ ? ref.getUsername()
+ : (source == null ? null : source.getUsername()),
+ StringUtils.isNotBlank(ref.getPassword())
+ ? ref.getPassword()
+ : (source == null ? null : source.getPassword()));
+ if (source == null) {
+ // The global HOP_MARKETPLACE_USERNAME / _PASSWORD pair belongs to the
operator's own
+ // repositories for the same reason; the repository-scoped variables
stay available so a
+ // project repository can still be given credentials without putting
them in the spec file.
+ repo.setGlobalEnvironmentCredentials(false);
+ }
+ repo.setPrimary(first);
+ repo.setEnabled(true);
+ return repo;
+ }
+
+ /**
+ * The configured repository whose credentials may be reused for {@code
url}, or null when none of
+ * them belongs to that origin. Install order, so the primary wins a tie.
+ */
+ private MarketplaceRepository configuredCredentialSource(String url) {
+ boolean anyStoredCredentials = false;
+ for (MarketplaceRepository repo : baseConfig.orderedRepositories()) {
+ if (StringUtils.isAllBlank(repo.getUsername(), repo.getPassword())) {
+ continue;
+ }
+ anyStoredCredentials = true;
+ if (repo.sameOriginAs(url)) {
+ return repo;
+ }
+ }
+ if (anyStoredCredentials) {
+ log.logBasic(
+ "Install spec repository "
+ + url
+ + " is not a configured marketplace repository: the configured
credentials are not"
+ + " sent to it");
+ }
+ return null;
+ }
+
private String resolveEnvVersion(HopInstallSpec env) {
if (StringUtils.isNotBlank(env.getHopVersion())) {
return env.getHopVersion();
diff --git
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/RepositoryOriginTest.java
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/RepositoryOriginTest.java
new file mode 100644
index 0000000000..d287ad1ebd
--- /dev/null
+++
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/RepositoryOriginTest.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.marketplace.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The origin comparison that decides whether stored credentials belong to a
repository URL, and the
+ * opt-out from the global environment credentials that goes with it.
+ */
+class RepositoryOriginTest {
+
+ @AfterEach
+ void restoreEnvironment() {
+ MarketplaceRepository.setEnvironmentForTesting(null);
+ }
+
+ @Test
+ void originIsSchemeHostAndPortWithDefaultPortMadeExplicit() {
+ assertEquals(
+ "https://nexus.example.org:443",
+
MarketplaceRepository.originOf("https://nexus.example.org/repository/hop/"));
+ assertEquals(
+ "http://nexus.example.org:80",
MarketplaceRepository.originOf("http://nexus.example.org/"));
+ assertEquals(
+ "https://nexus.example.org:8443",
+
MarketplaceRepository.originOf("https://nexus.example.org:8443/repository/hop/"));
+ }
+
+ @Test
+ void originIsCaseInsensitiveOnSchemeAndHost() {
+ assertEquals(
+
MarketplaceRepository.originOf("https://nexus.example.org/repository/hop/"),
+
MarketplaceRepository.originOf("HTTPS://Nexus.Example.ORG/repository/hop/"));
+ }
+
+ @Test
+ void originIsNullWhenThereIsNoHostToCompare() {
+ assertNull(MarketplaceRepository.originOf(null));
+ assertNull(MarketplaceRepository.originOf(" "));
+ assertNull(MarketplaceRepository.originOf("repository/hop/"));
+ assertNull(MarketplaceRepository.originOf("file:/var/repo/"));
+ assertNull(MarketplaceRepository.originOf("https://nexus example.org/"));
+ }
+
+ @Test
+ void sameOriginIgnoresThePath() {
+ MarketplaceRepository repo =
+ new MarketplaceRepository("nexus",
"https://nexus.example.org/repository/hop/");
+
assertTrue(repo.sameOriginAs("https://nexus.example.org/repository/hop-private/"));
+
assertTrue(repo.sameOriginAs("https://nexus.example.org:443/repository/hop/"));
+ }
+
+ @Test
+ void sameOriginRejectsADifferentHostSchemeOrPort() {
+ MarketplaceRepository repo =
+ new MarketplaceRepository("nexus",
"https://nexus.example.org/repository/hop/");
+ assertFalse(repo.sameOriginAs("https://evil.example.com/repository/hop/"));
+ assertFalse(repo.sameOriginAs("http://nexus.example.org/repository/hop/"));
+
assertFalse(repo.sameOriginAs("https://nexus.example.org:8443/repository/hop/"));
+ // A subdomain is a different host, not a relative of it.
+
assertFalse(repo.sameOriginAs("https://nexus.example.org.evil.com/repository/hop/"));
+ }
+
+ @Test
+ void anUnparseableUrlOnEitherSideNeverMatches() {
+ assertFalse(new MarketplaceRepository("nexus", "not a
url").sameOriginAs("not a url"));
+ assertFalse(
+ new MarketplaceRepository("nexus",
"https://nexus.example.org/").sameOriginAs(" "));
+ }
+
+ @Test
+ void globalEnvironmentCredentialsCanBeSuppressedWhileScopedOnesStillApply() {
+ MarketplaceRepository.setEnvironmentForTesting(
+ Map.of(
+ "HOP_MARKETPLACE_USERNAME", "global-user",
+ "HOP_MARKETPLACE_PASSWORD", "global-pass",
+ "HOP_MARKETPLACE_SPEC_USERNAME", "spec-user",
+ "HOP_MARKETPLACE_SPEC_PASSWORD", "spec-pass")
+ ::get);
+
+ MarketplaceRepository foreign =
+ new MarketplaceRepository("other", "https://other.example.org/");
+ foreign.setGlobalEnvironmentCredentials(false);
+ assertNull(foreign.effectiveUsername());
+ assertNull(foreign.effectivePassword());
+ assertFalse(foreign.hasCredentials());
+
+ // The repository-scoped pair names one repository, so it is still
honoured.
+ MarketplaceRepository spec = new MarketplaceRepository("spec",
"https://other.example.org/");
+ spec.setGlobalEnvironmentCredentials(false);
+ assertEquals("spec-user", spec.effectiveUsername());
+ assertEquals("spec-pass", spec.effectivePassword());
+ }
+
+ @Test
+ void suppressionIsNotPersisted() throws Exception {
+ MarketplaceRepository repo = new MarketplaceRepository("spec",
"https://other.example.org/");
+ repo.setGlobalEnvironmentCredentials(false);
+ String json =
org.apache.hop.core.json.HopJson.newMapper().writeValueAsString(repo);
+ assertFalse(json.contains("globalEnvironmentCredentials"));
+ assertTrue(
+ org.apache.hop.core.json.HopJson.newMapper()
+ .readValue(json, MarketplaceRepository.class)
+ .isGlobalEnvironmentCredentials());
+ }
+}
diff --git
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/EnvironmentApplierTest.java
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/EnvironmentApplierTest.java
index 00d474a35e..871b9e807c 100644
---
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/EnvironmentApplierTest.java
+++
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/EnvironmentApplierTest.java
@@ -19,6 +19,7 @@ package org.apache.hop.marketplace.env;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
@@ -27,6 +28,7 @@ import org.apache.hop.core.json.HopJson;
import org.apache.hop.core.logging.HopLogStore;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.marketplace.config.MarketplaceConfig;
+import org.apache.hop.marketplace.config.MarketplaceRepository;
import org.apache.hop.marketplace.install.InstallReceipt;
import org.apache.hop.marketplace.install.PluginInstaller;
import org.junit.jupiter.api.BeforeAll;
@@ -117,4 +119,111 @@ class EnvironmentApplierTest {
System.clearProperty("PROJECT_HOME");
}
}
+
+ private static MarketplaceConfig configuredWithPrivateRepository() {
+ MarketplaceConfig config = new MarketplaceConfig();
+ config.getRepositories().clear();
+ MarketplaceRepository repo =
+ new MarketplaceRepository(
+ "nexus", "https://nexus.example.org/repository/hop/", "operator",
"operator-secret");
+ repo.setPrimary(true);
+ config.getRepositories().add(repo);
+ return config;
+ }
+
+ private static HopInstallSpec specWithRepository(String id, String url) {
+ HopInstallSpec env = new HopInstallSpec();
+ HopInstallSpec.RepositoryRef ref = new HopInstallSpec.RepositoryRef();
+ ref.setId(id);
+ ref.setUrl(url);
+ env.getRepositories().add(ref);
+ return env;
+ }
+
+ @Test
+ void projectRepositoryOnAnotherHostDoesNotGetTheConfiguredCredentials() {
+ MarketplaceConfig config =
+ new EnvironmentApplier(new LogChannel("test"), tempDir,
configuredWithPrivateRepository())
+ .configFromEnv(
+ specWithRepository("project",
"https://evil.example.com/repository/hop/"));
+
+ MarketplaceRepository applied = config.primaryRepository();
+ assertEquals("project", applied.getId());
+ assertNull(applied.getUsername());
+ assertNull(applied.getPassword());
+ assertFalse(applied.isGlobalEnvironmentCredentials());
+ }
+
+ @Test
+ void projectRepositoryOnTheSameHostStillInheritsThem() {
+ MarketplaceConfig config =
+ new EnvironmentApplier(new LogChannel("test"), tempDir,
configuredWithPrivateRepository())
+ .configFromEnv(
+ specWithRepository("project",
"https://nexus.example.org/repository/hop-extra/"));
+
+ MarketplaceRepository applied = config.primaryRepository();
+ assertEquals("operator", applied.getUsername());
+ assertEquals("operator-secret", applied.getPassword());
+ assertTrue(applied.isGlobalEnvironmentCredentials());
+ }
+
+ @Test
+ void aDifferentPortOrSchemeIsADifferentRepository() {
+ for (String url :
+ new String[] {
+ "https://nexus.example.org:8443/repository/hop/",
+ "http://nexus.example.org/repository/hop/"
+ }) {
+ MarketplaceConfig config =
+ new EnvironmentApplier(new LogChannel("test"), tempDir,
configuredWithPrivateRepository())
+ .configFromEnv(specWithRepository("project", url));
+ assertNull(config.primaryRepository().getUsername(), url);
+ assertNull(config.primaryRepository().getPassword(), url);
+ }
+ }
+
+ @Test
+ void credentialsDeclaredByTheProjectAreAlwaysUsed() {
+ HopInstallSpec env = specWithRepository("project",
"https://other.example.com/repository/hop/");
+ env.getRepositories().get(0).setUsername("project-user");
+ env.getRepositories().get(0).setPassword("project-secret");
+
+ MarketplaceRepository applied =
+ new EnvironmentApplier(new LogChannel("test"), tempDir,
configuredWithPrivateRepository())
+ .configFromEnv(env)
+ .primaryRepository();
+ assertEquals("project-user", applied.getUsername());
+ assertEquals("project-secret", applied.getPassword());
+ }
+
+ @Test
+ void credentialsComeFromTheMatchingRepositoryNotOnlyFromThePrimary() {
+ MarketplaceConfig config = new MarketplaceConfig();
+ config.getRepositories().clear();
+ MarketplaceRepository primary =
+ new MarketplaceRepository("asf",
"https://repository.apache.org/content/groups/public/");
+ primary.setPrimary(true);
+ config.getRepositories().add(primary);
+ config
+ .getRepositories()
+ .add(
+ new MarketplaceRepository(
+ "acme", "https://nexus.example.org/repository/hop/",
"acme-user", "acme-secret"));
+
+ MarketplaceRepository applied =
+ new EnvironmentApplier(new LogChannel("test"), tempDir, config)
+ .configFromEnv(
+ specWithRepository("project",
"https://nexus.example.org/repository/hop-extra/"))
+ .primaryRepository();
+ assertEquals("acme-user", applied.getUsername());
+ assertEquals("acme-secret", applied.getPassword());
+ }
+
+ @Test
+ void specWithoutRepositoriesKeepsTheConfiguredOnes() {
+ MarketplaceConfig config =
+ new EnvironmentApplier(new LogChannel("test"), tempDir,
configuredWithPrivateRepository())
+ .configFromEnv(new HopInstallSpec());
+ assertEquals("operator", config.primaryRepository().getUsername());
+ }
}
diff --git
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/InstallSpecRepositoryCredentialScopeHttpTest.java
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/InstallSpecRepositoryCredentialScopeHttpTest.java
new file mode 100644
index 0000000000..91c0b52549
--- /dev/null
+++
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/InstallSpecRepositoryCredentialScopeHttpTest.java
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.marketplace.env;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.marketplace.config.MarketplaceConfig;
+import org.apache.hop.marketplace.config.MarketplaceRepository;
+import org.apache.hop.marketplace.resolve.MavenCoordinates;
+import org.apache.hop.marketplace.resolve.MavenRepositoryClient;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * End to end over real HTTP: what a download triggered by an install spec
actually puts on the
+ * wire. The unit tests assert which credentials are selected; these assert
that the selection is
+ * what reaches the server, which is the property issue #8393 is about.
+ *
+ * <p>Two loopback servers on different ports are two different origins, so
"the operator's
+ * repository" and "a repository the project names" can be told apart without
DNS.
+ */
+class InstallSpecRepositoryCredentialScopeHttpTest {
+
+ private static final MavenCoordinates COORDS =
+ new MavenCoordinates("org.apache.hop", "hop-tech-parquet", "2.19.0");
+
+ @TempDir private Path tempDir;
+
+ private HttpServer configured;
+ private HttpServer foreign;
+
+ /** Authorization headers seen by each server, in order. */
+ private final List<String> configuredAuth = new ArrayList<>();
+
+ private final List<String> foreignAuth = new ArrayList<>();
+
+ @BeforeAll
+ static void initLogging() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void startServers() throws IOException {
+ configured = start(configuredAuth);
+ foreign = start(foreignAuth);
+ }
+
+ @AfterEach
+ void stopServers() {
+ configured.stop(0);
+ foreign.stop(0);
+ }
+
+ private static HttpServer start(List<String> authorizations) throws
IOException {
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1",
0), 0);
+ server.createContext(
+ "/",
+ (HttpExchange exchange) -> {
+
authorizations.add(exchange.getRequestHeaders().getFirst("Authorization"));
+ byte[] body = "zip".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+ server.start();
+ return server;
+ }
+
+ private static String url(HttpServer server, String path) {
+ return "http://127.0.0.1:" + server.getAddress().getPort() + path;
+ }
+
+ /** hop-config with one private repository on the {@link #configured}
server. */
+ private MarketplaceConfig operatorConfig() {
+ MarketplaceConfig config = new MarketplaceConfig();
+ config.getRepositories().clear();
+ MarketplaceRepository repo =
+ new MarketplaceRepository(
+ "nexus", url(configured, "/repository/hop/"), "operator",
"operator-secret");
+ repo.setPrimary(true);
+ config.getRepositories().add(repo);
+ return config;
+ }
+
+ private HopInstallSpec specPointingAt(String repositoryUrl) {
+ HopInstallSpec spec = new HopInstallSpec();
+ HopInstallSpec.RepositoryRef ref = new HopInstallSpec.RepositoryRef();
+ ref.setId("project");
+ ref.setUrl(repositoryUrl);
+ spec.getRepositories().add(ref);
+ return spec;
+ }
+
+ private void downloadUsing(HopInstallSpec spec) throws Exception {
+ MarketplaceConfig applied =
+ new EnvironmentApplier(new LogChannel("test"), tempDir,
operatorConfig())
+ .configFromEnv(spec);
+ Path target = Files.createTempDirectory(tempDir,
"dl").resolve("plugin.zip");
+ new MavenRepositoryClient(new LogChannel("test"))
+ .downloadZip(applied.primaryRepository(), COORDS, target);
+ }
+
+ @Test
+ void aSpecPointingAtAnotherHostGetsNoAuthorizationHeader() throws Exception {
+ downloadUsing(specPointingAt(url(foreign, "/repository/hop/")));
+
+ assertEquals(1, foreignAuth.size(), "the foreign server must have served
the download");
+ assertNull(
+ foreignAuth.get(0), "operator credentials must not reach a repository
it never configured");
+ assertTrue(configuredAuth.isEmpty(), "the configured repository was not
contacted at all");
+ }
+
+ @Test
+ void aSpecPointingAtTheConfiguredRepositoryStillAuthenticates() throws
Exception {
+ downloadUsing(specPointingAt(url(configured, "/repository/hop-extra/")));
+
+ assertEquals(1, configuredAuth.size());
+ assertEquals(
+ "Basic "
+ + Base64.getEncoder()
+
.encodeToString("operator:operator-secret".getBytes(StandardCharsets.UTF_8)),
+ configuredAuth.get(0),
+ "a second path on the operator's own repository keeps working");
+ }
+
+ @Test
+ void aSpecMayCarryItsOwnCredentialsForAForeignHost() throws Exception {
+ HopInstallSpec spec = specPointingAt(url(foreign, "/repository/hop/"));
+ spec.getRepositories().get(0).setUsername("project-user");
+ spec.getRepositories().get(0).setPassword("project-secret");
+
+ downloadUsing(spec);
+
+ assertEquals(
+ "Basic "
+ + Base64.getEncoder()
+
.encodeToString("project-user:project-secret".getBytes(StandardCharsets.UTF_8)),
+ foreignAuth.get(0));
+ }
+}