You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ds...@apache.org on 2026/09/23 14:01:11 UTC
(solr) branch branch_10x updated (813fbec46ef -> 997d8a8b27d)
This is an automated email from the ASF dual-hosted git repository.
dsmiley pushed a change to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git
from 813fbec46ef SOLR-18455: actually disable metrics on <metrics enabled="false"/> (#4902)
new f619a3df21e SOLR-18224: New allowZkHosts setting for cross-collection join/streaming zkHost
new bbb87587aca SOLR-18225, SOLR-18228: Authorize ReplicationHandler and /update/extract per command
new 3bf8caad694 SOLR-18464: Fix three Antora build warnings in the ref guide (#4922)
new 997d8a8b27d SOLR-18473: Fix CrossDC Consumer NPE on shutdown. (#4932)
The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
changelog/unreleased/SOLR-18224-allowZkHosts.yml | 15 ++
...LR-18225-replication-permission-per-command.yml | 12 ++
.../unreleased/SOLR-18228-extract-update-perm.yml | 9 +
.../java/org/apache/solr/cloud/ZkController.java | 12 ++
.../java/org/apache/solr/core/CoreContainer.java | 37 ++++
.../src/java/org/apache/solr/core/NodeConfig.java | 20 ++
.../java/org/apache/solr/core/SolrXmlConfig.java | 17 ++
.../apache/solr/handler/ReplicationHandler.java | 19 +-
.../solr/response/PrometheusResponseWriter.java | 3 +
.../search/join/CrossCollectionJoinQParser.java | 16 ++
.../solr/security/AllowListZkHostChecker.java | 76 +++++++
.../src/test/org/apache/solr/core/TestSolrXml.java | 19 ++
.../ReplicationHandlerPermissionNameTest.java | 176 +++++++++++++++++
...licationHandlerRecoveryAuthIntegrationTest.java | 210 ++++++++++++++++++++
.../response/TestPrometheusResponseWriter.java | 9 +
.../search/join/CrossCollectionJoinQueryTest.java | 145 +++++++++++++-
.../BaseTestRuleBasedAuthorizationPlugin.java | 41 +++-
.../extraction/ExtractingRequestHandler.java | 2 +-
.../ExtractingRequestHandlerPermissionTest.java | 220 +++++++++++++++++++++
solr/server/solr/solr.xml | 1 +
.../configuration-guide/pages/configsets-api.adoc | 4 +-
.../pages/configuring-solr-xml.adoc | 19 ++
.../pages/rule-based-authorization-plugin.adoc | 2 +-
.../pages/document-enrichment-with-llms.adoc | 8 +-
.../query-guide/pages/join-query-parser.adoc | 17 +-
.../pages/major-changes-in-solr-10.adoc | 12 ++
.../solr/client/solrj/io/SolrClientCache.java | 35 ++++
.../solr/client/solrj/io/stream/TupleStream.java | 2 +
28 files changed, 1146 insertions(+), 12 deletions(-)
create mode 100644 changelog/unreleased/SOLR-18224-allowZkHosts.yml
create mode 100644 changelog/unreleased/SOLR-18225-replication-permission-per-command.yml
create mode 100644 changelog/unreleased/SOLR-18228-extract-update-perm.yml
create mode 100644 solr/core/src/java/org/apache/solr/security/AllowListZkHostChecker.java
create mode 100644 solr/core/src/test/org/apache/solr/handler/ReplicationHandlerPermissionNameTest.java
create mode 100644 solr/core/src/test/org/apache/solr/handler/ReplicationHandlerRecoveryAuthIntegrationTest.java
create mode 100644 solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerPermissionTest.java
(solr) 01/04: SOLR-18224: New allowZkHosts setting for cross-collection join/streaming zkHost
Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git
commit f619a3df21ee9869f3af749b6c821431c5adfef5
Author: Mark Robert Miller <ma...@apache.org>
AuthorDate: Mon Sep 21 21:02:24 2026 -0400
SOLR-18224: New allowZkHosts setting for cross-collection join/streaming zkHost
Add a solr.xml setting, allowZkHosts, that limits which ZooKeeper
connection strings a cross-collection join or streaming expression may
use (zkHost or solrConnection); an HTTP(s) solrConnection must be a
live node or be listed in allowUrls. The local SolrCloud ensemble is
always allowed. zkHost and solrUrl are now mutually exclusive in a
single cross-collection join clause.
Behavior change: the local-ensemble match is verbatim, including any
chroot: a node started with -DzkHost=zk1:2181/solr allows
zkHost="zk1:2181/solr" but not zkHost="zk1:2181" unless listed in
allowZkHosts.
Co-Authored-By: David Smiley <ds...@apache.org>
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
(cherry picked from commit b66758a1d03bc4c563d16b588d4be5c775ae5442)
---
changelog/unreleased/SOLR-18224-allowZkHosts.yml | 15 +++
.../java/org/apache/solr/cloud/ZkController.java | 12 ++
.../java/org/apache/solr/core/CoreContainer.java | 37 ++++++
.../src/java/org/apache/solr/core/NodeConfig.java | 20 +++
.../java/org/apache/solr/core/SolrXmlConfig.java | 17 +++
.../search/join/CrossCollectionJoinQParser.java | 16 +++
.../solr/security/AllowListZkHostChecker.java | 76 +++++++++++
.../src/test/org/apache/solr/core/TestSolrXml.java | 19 +++
.../search/join/CrossCollectionJoinQueryTest.java | 145 ++++++++++++++++++++-
solr/server/solr/solr.xml | 1 +
.../pages/configuring-solr-xml.adoc | 19 +++
.../query-guide/pages/join-query-parser.adoc | 17 ++-
.../pages/major-changes-in-solr-10.adoc | 10 ++
.../solr/client/solrj/io/SolrClientCache.java | 35 +++++
.../solr/client/solrj/io/stream/TupleStream.java | 2 +
15 files changed, 439 insertions(+), 2 deletions(-)
diff --git a/changelog/unreleased/SOLR-18224-allowZkHosts.yml b/changelog/unreleased/SOLR-18224-allowZkHosts.yml
new file mode 100644
index 00000000000..86116e14691
--- /dev/null
+++ b/changelog/unreleased/SOLR-18224-allowZkHosts.yml
@@ -0,0 +1,15 @@
+title: >-
+ New solr.xml setting allowZkHosts limits which ZooKeeper connection strings
+ a cross-collection join or streaming expression may use (zkHost or
+ solrConnection); an HTTP(s) solrConnection must be a live node or be listed
+ in allowUrls. The local SolrCloud ensemble is always allowed. zkHost
+ and solrUrl are now mutually exclusive in a single cross-collection join
+ clause. The local ensemble is matched verbatim, including any chroot: a node
+ started with `-DzkHost=zk1:2181/solr` allows `zkHost="zk1:2181/solr"` but not
+ `zkHost="zk1:2181"` unless listed in `allowZkHosts`.
+type: changed
+authors:
+ - name: Mark Robert Miller
+links:
+ - name: SOLR-18224
+ url: https://issues.apache.org/jira/browse/SOLR-18224
diff --git a/solr/core/src/java/org/apache/solr/cloud/ZkController.java b/solr/core/src/java/org/apache/solr/cloud/ZkController.java
index f006584edb2..9bb6acc7ea2 100644
--- a/solr/core/src/java/org/apache/solr/cloud/ZkController.java
+++ b/solr/core/src/java/org/apache/solr/cloud/ZkController.java
@@ -124,6 +124,7 @@ import org.apache.solr.core.SolrCoreInitializationException;
import org.apache.solr.handler.component.HttpShardHandler;
import org.apache.solr.logging.MDCLoggingContext;
import org.apache.solr.search.SolrIndexSearcher;
+import org.apache.solr.security.AllowListZkHostChecker;
import org.apache.solr.update.UpdateLog;
import org.apache.solr.util.AddressUtils;
import org.apache.solr.util.RTimer;
@@ -218,6 +219,7 @@ public class ZkController implements Closeable {
private final AtomicBoolean zkSessionExpired = new AtomicBoolean();
private final String zkServerAddress; // example: 127.0.0.1:54062/solr
+ private final AllowListZkHostChecker allowListZkHostChecker;
private final int localHostPort; // example: 54065
private final String hostName; // example: 127.0.0.1
@@ -302,6 +304,7 @@ public class ZkController implements Closeable {
this.cloudConfig = cloudConfig;
this.zkServerAddress = zkServerAddress;
+ this.allowListZkHostChecker = AllowListZkHostChecker.create(cc.getConfig(), zkServerAddress);
this.localHostPort = cloudConfig.getSolrHostPort();
this.hostName = normalizeHostName(cloudConfig.getHost());
this.nodeName = generateNodeName(this.hostName, Integer.toString(this.localHostPort));
@@ -1010,6 +1013,15 @@ public class ZkController implements Closeable {
return zkServerAddress;
}
+ /**
+ * Returns the ZooKeeper-host checker based on the {@code allowZkHosts} configuration in {@code
+ * solr.xml}, always allowing this cluster's ZK ensemble. Used by features that accept a
+ * caller-supplied {@code zkHost}.
+ */
+ public AllowListZkHostChecker getAllowListZkHostChecker() {
+ return allowListZkHostChecker;
+ }
+
boolean isClosed() {
return isClosed;
}
diff --git a/solr/core/src/java/org/apache/solr/core/CoreContainer.java b/solr/core/src/java/org/apache/solr/core/CoreContainer.java
index 04668858948..95e1f558564 100644
--- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java
+++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java
@@ -40,6 +40,7 @@ import io.opentelemetry.api.trace.Tracer;
import jakarta.inject.Singleton;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
+import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
@@ -68,6 +69,7 @@ import org.apache.solr.api.ClusterPluginsSource;
import org.apache.solr.api.ContainerPluginsRegistry;
import org.apache.solr.api.JerseyResource;
import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
import org.apache.solr.client.solrj.impl.HttpSolrClient;
import org.apache.solr.client.solrj.io.SolrClientCache;
import org.apache.solr.client.solrj.util.SolrIdentifierValidator;
@@ -798,6 +800,9 @@ public class CoreContainer {
new HttpSolrClientProvider(cfg.getUpdateShardHandlerConfig(), solrMetricsContext);
updateShardHandler.initializeMetrics(solrMetricsContext, Attributes.empty());
solrClientCache = new SolrClientCache(solrClientProvider.getSolrClient());
+ // Validate caller-supplied zkHost/solrConnection (cross-collection join, streaming
+ // expressions).
+ solrClientCache.setConnectionValidator(this::validateSolrConnection);
Map<String, CacheConfig> cachesConfig = cfg.getCachesConfig();
if (cachesConfig.isEmpty()) {
@@ -1637,6 +1642,38 @@ public class CoreContainer {
return allowListUrlChecker;
}
+ /**
+ * Validates a connection to a SolrCloud cluster: ZooKeeper via {@link
+ * ZkController#getAllowListZkHostChecker()} (never allowed in standalone mode), HTTP via {@link
+ * AllowListUrlChecker} (live nodes of this cluster are allowed).
+ *
+ * @throws SolrException FORBIDDEN if not allowed
+ */
+ private void validateSolrConnection(CloudSolrClient.CloudSolrClientConnection solrConnection) {
+ ZkController zkController = getZkController();
+ if (solrConnection.isZookeeper()) {
+ String zkHost = solrConnection.toString();
+ if (zkController == null) {
+ throw new SolrException(
+ ErrorCode.FORBIDDEN,
+ "ZooKeeper host '" + zkHost + "' is not allowed when Solr is not in SolrCloud mode.");
+ }
+ if (!zkController.getAllowListZkHostChecker().isAllowed(zkHost)) {
+ throw new SolrException(
+ ErrorCode.FORBIDDEN, SolrClientCache.zkHostRejectionMessage(zkHost));
+ }
+ } else {
+ try {
+ allowListUrlChecker.checkAllowList(
+ solrConnection.quorumItems(),
+ zkController == null ? null : zkController.getClusterState());
+ } catch (MalformedURLException e) {
+ throw new SolrException(
+ ErrorCode.BAD_REQUEST, "Invalid URL in solrConnection: " + solrConnection, e);
+ }
+ }
+ }
+
/**
* Creates a new core based on a CoreDescriptor.
*
diff --git a/solr/core/src/java/org/apache/solr/core/NodeConfig.java b/solr/core/src/java/org/apache/solr/core/NodeConfig.java
index 53e9a07315b..bde3d6932d1 100644
--- a/solr/core/src/java/org/apache/solr/core/NodeConfig.java
+++ b/solr/core/src/java/org/apache/solr/core/NodeConfig.java
@@ -120,6 +120,8 @@ public class NodeConfig {
private final String defaultZkHost;
+ private final List<String> allowZkHosts;
+
private NodeConfig(
String nodeName,
Path coreRootDirectory,
@@ -154,6 +156,7 @@ public class NodeConfig {
String defaultZkHost,
Set<Path> allowPaths,
List<String> allowUrls,
+ List<String> allowZkHosts,
boolean hideStackTraces,
String configSetServiceClass,
String modules,
@@ -192,6 +195,7 @@ public class NodeConfig {
this.defaultZkHost = defaultZkHost;
this.allowPaths = allowPaths;
this.allowUrls = allowUrls;
+ this.allowZkHosts = allowZkHosts;
this.hideStackTraces = hideStackTraces;
this.configSetServiceClass = configSetServiceClass;
this.modules = modules;
@@ -437,6 +441,15 @@ public class NodeConfig {
return allowUrls;
}
+ /**
+ * Allow-list of remote ZooKeeper connection strings that may be passed to query/streaming
+ * features which accept a {@code zkHost} parameter (e.g. cross-collection join, /stream
+ * expressions). The local cluster's own ZK ensemble is always trusted and need not be listed.
+ */
+ public List<String> getAllowZkHosts() {
+ return allowZkHosts;
+ }
+
public boolean hideStackTraces() {
return hideStackTraces;
}
@@ -600,6 +613,7 @@ public class NodeConfig {
private String defaultZkHost;
private Set<Path> allowPaths = Set.of();
private List<String> allowUrls = List.of();
+ private List<String> allowZkHosts = List.of();
private boolean hideStackTrace =
!EnvUtils.getPropertyAsBool("solr.responses.stacktrace.enabled", true);
@@ -804,6 +818,11 @@ public class NodeConfig {
return this;
}
+ public NodeConfigBuilder setAllowZkHosts(List<String> zkHosts) {
+ this.allowZkHosts = zkHosts;
+ return this;
+ }
+
public NodeConfigBuilder setHideStackTrace(boolean hide) {
this.hideStackTrace = hide;
return this;
@@ -895,6 +914,7 @@ public class NodeConfig {
defaultZkHost,
allowPaths,
allowUrls,
+ allowZkHosts,
hideStackTrace,
configSetServiceClass,
modules,
diff --git a/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java b/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java
index de58f5f50aa..5c55bc00eef 100644
--- a/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java
+++ b/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java
@@ -376,6 +376,9 @@ public class SolrXmlConfig {
case "allowUrls":
builder.setAllowUrls(separateStrings(it.txt()));
break;
+ case "allowZkHosts":
+ builder.setAllowZkHosts(separateZkHosts(it.txt()));
+ break;
default:
throw new SolrException(
SolrException.ErrorCode.SERVER_ERROR,
@@ -398,6 +401,20 @@ public class SolrXmlConfig {
return Arrays.asList(COMMA_SEPARATED_PATTERN.split(commaSeparatedString));
}
+ /**
+ * Like {@link #separateStrings(String)} but drops blank entries so a stray comma or unset {@code
+ * ${prop:}} placeholder does not produce an empty allow-list entry. {@code allowUrls}/{@code
+ * allowPaths} keep their pre-existing behavior.
+ */
+ private static List<String> separateZkHosts(String commaSeparatedString) {
+ if (StrUtils.isNullOrEmpty(commaSeparatedString)) {
+ return List.of();
+ }
+ return Arrays.stream(COMMA_SEPARATED_PATTERN.split(commaSeparatedString))
+ .filter(s -> !s.isBlank())
+ .toList();
+ }
+
private static Set<Path> separatePaths(String commaSeparatedString) {
if (StrUtils.isNullOrEmpty(commaSeparatedString)) {
return Set.of();
diff --git a/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java b/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java
index 696c5b286cb..b336124bb4b 100644
--- a/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java
+++ b/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java
@@ -23,9 +23,11 @@ import java.util.Iterator;
import java.util.Set;
import org.apache.lucene.search.Query;
import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import org.apache.solr.common.SolrException;
import org.apache.solr.common.params.ModifiableSolrParams;
import org.apache.solr.common.params.ShardParams;
import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.core.CoreContainer;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.search.QParser;
import org.apache.solr.search.QueryParsing;
@@ -51,6 +53,7 @@ public class CrossCollectionJoinQParser extends QParser {
QueryParsing.TYPE,
QueryParsing.V,
ZK_HOST,
+ SOLR_CONNECTION,
SOLR_URL,
FROM_INDEX,
FROM,
@@ -80,6 +83,11 @@ public class CrossCollectionJoinQParser extends QParser {
String zkHost = localParams.get(ZK_HOST);
String solrConnectionString = localParams.get(SOLR_CONNECTION);
String solrUrl = localParams.get(SOLR_URL);
+ // CrossCollectionJoinQuery.getDocSet() silently prefers zkHost when both are set.
+ if (zkHost != null && solrUrl != null) {
+ throw new SyntaxError(
+ "zkHost and solrUrl are mutually exclusive; specify at most one of them.");
+ }
// Test if this is a valid solr url.
if (solrUrl != null) {
if (allowSolrUrls == null) {
@@ -117,6 +125,14 @@ public class CrossCollectionJoinQParser extends QParser {
} else if (zkHost != null && !zkHost.isBlank()) {
solrConnection = CloudSolrClient.CloudSolrClientConnection.parse(zkHost);
}
+ CoreContainer cc = req.getCoreContainer();
+ if (solrConnection != null && cc != null) { // no CoreContainer -- unit test
+ try {
+ cc.getSolrClientCache().validateConnection(solrConnection);
+ } catch (SolrException e) {
+ throw new SyntaxError(e.getMessage(), e);
+ }
+ }
return new CrossCollectionJoinQuery(
query,
solrConnection,
diff --git a/solr/core/src/java/org/apache/solr/security/AllowListZkHostChecker.java b/solr/core/src/java/org/apache/solr/security/AllowListZkHostChecker.java
new file mode 100644
index 00000000000..c6ec76f7f4c
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/security/AllowListZkHostChecker.java
@@ -0,0 +1,76 @@
+/*
+ * 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.solr.security;
+
+import java.util.Collection;
+import java.util.Set;
+import org.apache.solr.core.NodeConfig;
+
+/**
+ * Validates a caller-supplied ZooKeeper connection string against the local cluster's ensemble and
+ * a configured allow-list. The ZooKeeper counterpart of {@link AllowListUrlChecker}.
+ *
+ * <p>A {@code zkHost} value is allowed if any of the following are true:
+ *
+ * <ul>
+ * <li>It is {@code null} (caller did not specify one; Solr will use the local ensemble).
+ * <li>It equals the local SolrCloud ZooKeeper connection string verbatim.
+ * <li>It appears verbatim in the {@code allowZkHosts} list configured in {@code solr.xml}.
+ * </ul>
+ *
+ * <p>Comparison is strict character-for-character; no normalization of trailing slashes, chroot
+ * suffixes, host order, whitespace, or case. Operators must list each form they intend to accept.
+ */
+public class AllowListZkHostChecker {
+
+ /** {@link org.apache.solr.core.SolrXmlConfig} property to configure the allowed ZK hosts. */
+ public static final String ZK_HOST_ALLOW_LIST = "allowZkHosts";
+
+ private final Set<String> allowedZkHosts;
+ private final String localZkHost;
+
+ /**
+ * @param allowedZkHosts the verbatim ZooKeeper connection strings configured under {@link
+ * #ZK_HOST_ALLOW_LIST} in {@code solr.xml}. May be null or empty.
+ * @param localZkHost the local SolrCloud ZooKeeper connection string. May be null.
+ */
+ public AllowListZkHostChecker(Collection<String> allowedZkHosts, String localZkHost) {
+ this.allowedZkHosts =
+ allowedZkHosts == null || allowedZkHosts.isEmpty() ? Set.of() : Set.copyOf(allowedZkHosts);
+ this.localZkHost = localZkHost;
+ }
+
+ /** Creates a checker from the {@link NodeConfig} and the local ZK address. */
+ public static AllowListZkHostChecker create(NodeConfig config, String localZkHost) {
+ return new AllowListZkHostChecker(config.getAllowZkHosts(), localZkHost);
+ }
+
+ /**
+ * Returns true if {@code zkHost} is null, equals the local ensemble, or appears verbatim in the
+ * configured allow-list.
+ */
+ public boolean isAllowed(String zkHost) {
+ if (zkHost == null) {
+ return true;
+ }
+ if (zkHost.equals(localZkHost)) {
+ return true;
+ }
+ return allowedZkHosts.contains(zkHost);
+ }
+}
diff --git a/solr/core/src/test/org/apache/solr/core/TestSolrXml.java b/solr/core/src/test/org/apache/solr/core/TestSolrXml.java
index eeab65afbf1..8aa23659c7f 100644
--- a/solr/core/src/test/org/apache/solr/core/TestSolrXml.java
+++ b/solr/core/src/test/org/apache/solr/core/TestSolrXml.java
@@ -21,6 +21,7 @@ import static org.hamcrest.core.StringContains.containsString;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
@@ -207,6 +208,24 @@ public class TestSolrXml extends SolrTestCaseJ4 {
assertEquals("hostPort", 8000, cfg.getCloudConfig().getSolrHostPort());
}
+ // SOLR-18224: allowZkHosts is parsed via a helper that drops blank entries so a
+ // stray comma or unset ${prop:} expansion cannot produce an empty allow-list entry.
+ public void testAllowZkHostsParsing() {
+ String withEntries =
+ "<solr><str name=\"allowZkHosts\">zk-a:2181,,zk-b:2181/chroot, ,zk-c:2181</str></solr>";
+ NodeConfig cfgEntries = SolrXmlConfig.fromString(solrHome, withEntries);
+ assertEquals(
+ List.of("zk-a:2181", "zk-b:2181/chroot", "zk-c:2181"), cfgEntries.getAllowZkHosts());
+
+ String empty = "<solr><str name=\"allowZkHosts\"></str></solr>";
+ NodeConfig cfgEmpty = SolrXmlConfig.fromString(solrHome, empty);
+ assertEquals(List.of(), cfgEmpty.getAllowZkHosts());
+
+ String absent = "<solr></solr>";
+ NodeConfig cfgAbsent = SolrXmlConfig.fromString(solrHome, absent);
+ assertEquals(List.of(), cfgAbsent.getAllowZkHosts());
+ }
+
public void testIntAsLongBad() {
String bad = "" + TestUtil.nextLong(random(), Integer.MAX_VALUE, Long.MAX_VALUE);
String solrXml =
diff --git a/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java b/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java
index 078fd4dcf98..341be4d6223 100644
--- a/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java
+++ b/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java
@@ -40,6 +40,7 @@ import org.apache.solr.embedded.JettySolrRunner;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.request.SolrQueryRequestBase;
import org.apache.solr.search.QueryParsing;
+import org.apache.solr.search.SyntaxError;
import org.apache.solr.util.SolrJMetricTestUtils;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -161,6 +162,7 @@ public class CrossCollectionJoinQueryTest extends SolrCloudTestCase {
String url = runner.getBaseUrl().toString();
System.setProperty("test.ccjoin.solr.url." + i, url);
}
+ String quorumHosts = cluster.getSolrClient().getClusterStateProvider().getQuorumHosts();
try {
// now we need to re-upload our config , now that we know a valid solr url for the cluster.
CloudSolrClient client = cluster.getSolrClient();
@@ -200,11 +202,12 @@ public class CrossCollectionJoinQueryTest extends SolrCloudTestCase {
"{!join method=crossCollection solrUrl=\"%s\" fromIndex=products from=product_id_s to=product_id_s}size_s:M",
getSolrUrl()),
true);
+ // The local cluster's ZK ensemble is always allowed; no allowZkHosts entry needed.
testCcJoinQuery(
String.format(
Locale.ROOT,
"{!join method=crossCollection zkHost=\"%s\" fromIndex=products from=product_id_s to=product_id_s}size_s:M",
- client.getClusterStateProvider().getQuorumHosts()),
+ quorumHosts),
true);
testCcJoinQuery(
String.format(
@@ -326,6 +329,146 @@ public class CrossCollectionJoinQueryTest extends SolrCloudTestCase {
}
}
+ @Test
+ public void testAllowZkHostsList() throws Exception {
+ setupIndexes(false);
+
+ int i = 0;
+ for (JettySolrRunner runner : cluster.getJettySolrRunners()) {
+ i++;
+ System.setProperty("test.ccjoin.solr.url." + i, runner.getBaseUrl().toString());
+ }
+ String quorumHosts = cluster.getSolrClient().getClusterStateProvider().getQuorumHosts();
+ try {
+ CloudSolrClient client = cluster.getSolrClient();
+ cluster.uploadConfigSet(configset("ccjoin"), "ccjoin");
+ CollectionAdminRequest.Reload.reloadCollection("products").process(client);
+ CollectionAdminRequest.Reload.reloadCollection("parts").process(client);
+
+ Exception unlisted =
+ expectThrows(
+ Exception.class,
+ () ->
+ testCcJoinQuery(
+ "{!join method=crossCollection zkHost=\"other-zk.example.com:2181/solr\""
+ + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+ true));
+ assertTrue(
+ "Unexpected message: " + unlisted.getMessage(),
+ unlisted.getMessage().contains("other-zk.example.com:2181/solr"));
+
+ // The local cluster's ZK ensemble is always allowed, so the local quorum string must
+ // succeed. product_id_i (not _s) so the join does not default to routed-by-join-key --
+ // setupIndexes(false) does not shard-route by that field.
+ testCcJoinQuery(
+ String.format(
+ Locale.ROOT,
+ "{!join method=crossCollection zkHost=\"%s\""
+ + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+ quorumHosts),
+ true);
+
+ Exception unlistedHttp =
+ expectThrows(
+ Exception.class,
+ () ->
+ testCcJoinQuery(
+ "{!join method=crossCollection"
+ + " solrConnection=\"http://other-solr.example.com:8983/solr\""
+ + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+ true));
+ assertTrue(
+ "Unexpected message: " + unlistedHttp.getMessage(),
+ unlistedHttp.getMessage().contains("other-solr.example.com")
+ && unlistedHttp.getMessage().contains("allowUrls"));
+
+ Exception both =
+ expectThrows(
+ Exception.class,
+ () ->
+ testCcJoinQuery(
+ String.format(
+ Locale.ROOT,
+ "{!join method=crossCollection zkHost=\"%s\" solrUrl=\"%s\""
+ + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+ quorumHosts,
+ getSolrUrl()),
+ true));
+ assertTrue(
+ "Unexpected message: " + both.getMessage(),
+ both.getMessage().contains("mutually exclusive"));
+
+ // Mutual exclusion is checked before either allow-list.
+ Exception bothUnlistedZk =
+ expectThrows(
+ Exception.class,
+ () ->
+ testCcJoinQuery(
+ String.format(
+ Locale.ROOT,
+ "{!join method=crossCollection"
+ + " zkHost=\"other-zk.example.com:2181/solr\" solrUrl=\"%s\""
+ + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+ getSolrUrl()),
+ true));
+ assertTrue(
+ "Unexpected message: " + bothUnlistedZk.getMessage(),
+ bothUnlistedZk.getMessage().contains("mutually exclusive"));
+
+ Exception bothUnlistedUrl =
+ expectThrows(
+ Exception.class,
+ () ->
+ testCcJoinQuery(
+ String.format(
+ Locale.ROOT,
+ "{!join method=crossCollection zkHost=\"%s\""
+ + " solrUrl=\"http://other-solr.example.com:18080/solr\""
+ + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+ quorumHosts),
+ true));
+ assertTrue(
+ "Unexpected message: " + bothUnlistedUrl.getMessage(),
+ bothUnlistedUrl.getMessage().contains("mutually exclusive"));
+
+ } finally {
+ int j = 0;
+ for (JettySolrRunner runner : cluster.getJettySolrRunners()) {
+ j++;
+ System.getProperties().remove("test.ccjoin.solr.url." + j);
+ }
+ }
+ }
+
+ @Test
+ public void testZkHostAndSolrUrlMutualExclusionAtParser() throws SyntaxError {
+ ModifiableSolrParams localParams = new ModifiableSolrParams();
+ localParams.set(QueryParsing.V, "*:*");
+ localParams.set(CrossCollectionJoinQParser.ZK_HOST, "trusted-zk:2181/chroot");
+ localParams.set(CrossCollectionJoinQParser.SOLR_URL, "http://trusted-solr:8983/solr");
+ localParams.set(CrossCollectionJoinQParser.FROM_INDEX, "products");
+ localParams.set(CrossCollectionJoinQParser.FROM, "product_id_s");
+ localParams.set(CrossCollectionJoinQParser.TO, "product_id_s");
+ localParams.set(CrossCollectionJoinQParser.ROUTED_BY_JOIN_KEY, "false");
+
+ ModifiableSolrParams requestParams = new ModifiableSolrParams();
+ try (SolrQueryRequest req = new SolrQueryRequestBase(null, requestParams) {}) {
+ CrossCollectionJoinQParser parser =
+ new CrossCollectionJoinQParser(
+ null, localParams, requestParams, req, "product_id_s", null);
+ SyntaxError e =
+ expectThrows(
+ SyntaxError.class,
+ "Expected SyntaxError when both zkHost and solrUrl are specified",
+ parser::parse);
+ assertTrue(
+ "Unexpected message: " + e.getMessage(),
+ e.getMessage().contains("mutually exclusive")
+ && e.getMessage().contains("zkHost")
+ && e.getMessage().contains("solrUrl"));
+ }
+ }
+
@Test
public void testShardsPreferenceRequestParamPropagation() throws Exception {
// shards.preference set as a request-level param (not in localParams) should be
diff --git a/solr/server/solr/solr.xml b/solr/server/solr/solr.xml
index ff7c7505c69..bfc74a30156 100644
--- a/solr/server/solr/solr.xml
+++ b/solr/server/solr/solr.xml
@@ -33,6 +33,7 @@
<str name="modules">${solr.modules:}</str>
<str name="allowPaths">${solr.security.allow.paths:}</str>
<str name="allowUrls">${solr.security.allow.urls:}</str>
+ <str name="allowZkHosts">${solr.security.allow.zk.hosts:}</str>
<str name="hideStackTrace">${solr.hideStackTrace:false}</str>
<int name="indexSearcherExecutorThreads">${solr.searchThreads:0}</int>
diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc
index 66e6d21dc7a..bc22a2ed2f5 100644
--- a/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc
+++ b/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc
@@ -37,6 +37,7 @@ The default `solr.xml` file is found in `$SOLR_TIP/server/solr/solr.xml` and loo
<str name="modules">${solr.modules:}</str>
<str name="allowPaths">${solr.security.allow.paths:}</str>
<str name="allowUrls">${solr.security.allow.urls:}</str>
+ <str name="allowZkHosts">${solr.security.allow.zk.hosts:}</str>
<str name="hideStackTrace">${solr.hideStackTrace:false}</str>
<solrcloud>
@@ -273,6 +274,24 @@ In SolrCloud mode, the allow-list is automatically configured to include all liv
The allow-list can also be configured with the `solr.security.allow.urls` system property in `solr.in.sh` / `solr.in.cmd`.
If you need to disable this feature for backwards compatibility, you can set the system property `solr.security.allow.urls.enabled=false`.
+[#allow-zk-hosts]
+`allowZkHosts`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Comma-separated list of ZooKeeper connection strings of other SolrCloud clusters that may be passed as a `zkHost` (or ZooKeeper-based `solrConnection`) parameter to features that fetch data from another cluster, such as cross-collection join and streaming expressions.
+The local SolrCloud ZooKeeper ensemble is always allowed and need not be listed.
+Solr in standalone (non-SolrCloud) mode does not allow any `zkHost`.
++
+Matching is character-for-character against the full connection string -- e.g. `zk1:2181,zk2:2181,zk3:2181/solr` is a different value from `zk1:2181,zk2:2181,zk3:2181` or from the same hosts in a different order.
+The local ensemble's value includes any chroot suffix configured at startup: if Solr was started with `-DzkHost=zk1:2181/solr`, then `zkHost="zk1:2181/solr"` is allowed but `zkHost="zk1:2181"` is not unless listed.
+A value that is not allowed is rejected with an error mentioning `allowZkHosts`.
++
+The allow-list can also be configured with the `solr.security.allow.zk.hosts` system property in `solr.in.sh` / `solr.in.cmd`.
+
`hideStackTrace`::
+
[%autowidth,frame=none]
diff --git a/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc
index e62ec8b8614..bd0f80d2914 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc
@@ -288,6 +288,13 @@ This restriction is necessary to prevent an attacker from using Solr to explore
</queryParser>
----
+[NOTE]
+====
+The `zkHost` parameter is governed by a node-level allow-list in `solr.xml` (`<str name="allowZkHosts">`), *not* by `solrconfig.xml`.
+The local SolrCloud ZooKeeper ensemble is always trusted -- only remote ensembles need to be listed.
+See <<Cross Collection Join Query Parameters>> below and the `solr.xml` reference for details.
+====
+
=== Cross Collection Join Query Parameters
`fromIndex`::
@@ -309,6 +316,9 @@ The name of the external Solr collection to be queried to retrieve the set of jo
The connection string to be used to connect to SolrCloud cluster via ZooKeeper or HTTP(s).
`solrConnection`, `zkHost` and `solrUrl` are optional parameters, and at most one of them should be specified.
If neither `solrConnection`, `zkHost` nor `solrUrl` are specified, the local ZooKeeper cluster will be used.
++
+A ZooKeeper connection string is validated like `zkHost` below.
+An HTTP(s) connection string must be a live node of the local cluster or be listed in the `allowUrls` entry in `solr.xml`; otherwise the request is rejected.
`zkHost`::
+
@@ -318,9 +328,14 @@ If neither `solrConnection`, `zkHost` nor `solrUrl` are specified, the local Zoo
|===
+
The connection string to be used to connect to ZooKeeper.
-`zkHost` and `solrUrl` are both optional parameters, and at most one of them should be specified.
+`zkHost` and `solrUrl` are both optional parameters, and at most one of them may be specified; passing both is rejected.
If neither `zkHost` nor `solrUrl` are specified, the local ZooKeeper cluster will be used.
This parameter is deprecated. Use `solrConnection` instead.
++
+An explicit `zkHost` value must either match the local SolrCloud ensemble verbatim or be listed verbatim in the node-level `<str name="allowZkHosts">` entry in `solr.xml`.
+The match is a character-for-character exact comparison of the full connection string -- e.g. `zk1:2181,zk2:2181,zk3:2181/solr` is a different value from `zk1:2181,zk2:2181,zk3:2181` or from the same hosts in a different order.
+The local value includes any chroot suffix configured at startup -- so for a node started with `-DzkHost=zk1:2181/solr`, `zkHost="zk1:2181/solr"` is allowed while `zkHost="zk1:2181"` is not.
+If the value does not match, the request is rejected with a `SyntaxError`.
`solrUrl`::
+
diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
index 57538cb2c58..643800e3181 100644
--- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
+++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
@@ -66,6 +66,16 @@ The universal connection string is now supported in the following components and
* Updated Solr CLI `-s` short option to invoke `--solr-connection` instead of `--solr-url`, which is a backwards compatible change.
* Added `solrConnection` parameter to the Join Query Parser.
+=== Connecting to Other Clusters
+
+Streaming expressions and the cross-collection join query parser now restrict which SolrCloud clusters a `zkHost` or `solrConnection` parameter may refer to:
+
+* A ZooKeeper connection string must match the local cluster's exactly (including any chroot), or be listed in the new xref:configuration-guide:configuring-solr-xml.adoc#allow-zk-hosts[`allowZkHosts`] setting in `solr.xml` (or the `solr.security.allow.zk.hosts` system property).
+* An HTTP(S) connection string must refer to live nodes of the local cluster, or to hosts listed in xref:configuration-guide:configuring-solr-xml.adoc#allow-urls[`allowUrls`].
+* The cross-collection join query parser no longer accepts both `zkHost` and `solrUrl`; specify at most one.
+
+If you use these features to query another cluster, add its connection details to the corresponding setting before upgrading.
+
=== v2 API
Starting in Solr 10.1 it is no longer possible for users to disable the v2 API by use of the `solr.api.v2.enabled` system property, and the Solr server and tooling (`bin/solr`, Admin UI, etc.) will start using these APIs internally.
diff --git a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java
index b2e698c3cd3..89a89ea9eb6 100644
--- a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java
+++ b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java
@@ -24,6 +24,7 @@ import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
import org.apache.solr.client.solrj.SolrClient;
import org.apache.solr.client.solrj.impl.CloudSolrClient;
import org.apache.solr.client.solrj.impl.HttpSolrClient;
@@ -52,6 +53,7 @@ public class SolrClientCache implements Closeable {
private final HttpSolrClient httpSolrClient;
private final AtomicBoolean isClosed = new AtomicBoolean(false);
private final AtomicReference<String> defaultZkHost = new AtomicReference<>();
+ private volatile Consumer<CloudSolrClient.CloudSolrClientConnection> connectionValidator;
public SolrClientCache() {
this.httpSolrClient = null;
@@ -65,6 +67,37 @@ public class SolrClientCache implements Closeable {
this.basicAuthCredentials = basicAuthCredentials;
}
+ /**
+ * Installs a connection validator that {@link
+ * #getCloudSolrClient(CloudSolrClient.CloudSolrClientConnection)} consults on every call, before
+ * any ZK or HTTP contact. The validator rejects a connection by throwing, typically a {@code 403
+ * Forbidden} {@link org.apache.solr.common.SolrException}. A {@code null} validator disables
+ * validation.
+ *
+ * <p>The validator is invoked while holding the cache monitor, so it must be non-blocking and
+ * must not perform I/O.
+ */
+ public void setConnectionValidator(
+ Consumer<CloudSolrClient.CloudSolrClientConnection> connectionValidator) {
+ this.connectionValidator = connectionValidator;
+ }
+
+ /** Throws if the installed validator rejects the connection. */
+ public void validateConnection(CloudSolrClient.CloudSolrClientConnection solrConnection) {
+ var validator = connectionValidator;
+ if (validator != null) {
+ validator.accept(solrConnection);
+ }
+ }
+
+ /** Message for a rejected ZooKeeper connection string. */
+ public static String zkHostRejectionMessage(String connectionString) {
+ return "ZooKeeper host '"
+ + connectionString
+ + "' is not on the 'allowZkHosts' allow-list in solr.xml and does not match the local"
+ + " cluster's ZK ensemble.";
+ }
+
public void setDefaultZKHost(String zkHost) {
if (zkHost != null) {
zkHost = zkHost.split("/")[0];
@@ -88,6 +121,8 @@ public class SolrClientCache implements Closeable {
public synchronized CloudSolrClient getCloudSolrClient(
CloudSolrClient.CloudSolrClientConnection solrConnection) {
ensureOpen();
+ // before the cache lookup, so cached clients are also subject to the validator
+ validateConnection(solrConnection);
return cloudSolClients.computeIfAbsent(
solrConnection, sc -> newCloudSolrClient(sc, httpSolrClient, useAclForZookeeper(sc)));
}
diff --git a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/TupleStream.java b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/TupleStream.java
index 3dc3ceb6f78..1b94038d482 100644
--- a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/TupleStream.java
+++ b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/TupleStream.java
@@ -150,6 +150,8 @@ public abstract class TupleStream implements Closeable, Serializable, MapWriter
localSolrClientCache; // tracks any locally allocated cache that needs to be closed locally
if (solrClientCache
== null) { // streamContext was null OR streamContext.getSolrClientCache() returned null
+ // Note: this cache doesn't validate connections. Within Solr, callers (StreamHandler,
+ // CrossCollectionJoinQuery) supply CoreContainer's cache via the StreamContext.
solrClientCache = localSolrClientCache = new SolrClientCache();
} else {
localSolrClientCache = null;
(solr) 02/04: SOLR-18225, SOLR-18228: Authorize ReplicationHandler and /update/extract per command
Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git
commit bbb87587acaae7950fb5b68ffdf38c790940df83
Author: Mark Robert Miller <ma...@apache.org>
AuthorDate: Mon Sep 21 21:05:13 2026 -0400
SOLR-18225, SOLR-18228: Authorize ReplicationHandler and /update/extract per command
The per-core /solr/{core}/replication endpoint is now authorized per
command: state-changing commands (backup, restore, deletebackup,
disablereplication, enablereplication, abortfetch, disablepoll,
enablepoll, fetchindex, and any unlisted command) require the "update"
permission, while read-only commands (indexversion, filelist,
filecontent, details, commits, restorestatus) continue to require
"read".
/update/extract (ExtractingRequestHandler) now requires "update"
rather than "read", since it indexes documents.
Co-Authored-By: David Smiley <ds...@apache.org>
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
(cherry picked from commit b43ab202158705ec77aa7046ebe790a46e609b15)
---
...LR-18225-replication-permission-per-command.yml | 12 ++
.../unreleased/SOLR-18228-extract-update-perm.yml | 9 +
.../apache/solr/handler/ReplicationHandler.java | 19 +-
.../ReplicationHandlerPermissionNameTest.java | 176 +++++++++++++++++
...licationHandlerRecoveryAuthIntegrationTest.java | 210 ++++++++++++++++++++
.../BaseTestRuleBasedAuthorizationPlugin.java | 41 +++-
.../extraction/ExtractingRequestHandler.java | 2 +-
.../ExtractingRequestHandlerPermissionTest.java | 220 +++++++++++++++++++++
.../pages/major-changes-in-solr-10.adoc | 2 +
9 files changed, 688 insertions(+), 3 deletions(-)
diff --git a/changelog/unreleased/SOLR-18225-replication-permission-per-command.yml b/changelog/unreleased/SOLR-18225-replication-permission-per-command.yml
new file mode 100644
index 00000000000..53768f63705
--- /dev/null
+++ b/changelog/unreleased/SOLR-18225-replication-permission-per-command.yml
@@ -0,0 +1,12 @@
+title: >-
+ The per-core /solr/{core}/replication endpoint is now authorized per command:
+ state-changing commands require the "update" permission, while read-only
+ commands continue to require "read".
+type: changed
+authors:
+ - name: Mark Robert Miller
+links:
+ - name: SOLR-18225
+ url: https://issues.apache.org/jira/browse/SOLR-18225
+important_notes:
+ - "Per-core `/solr/{core}/replication` commands are authorized individually. `backup`, `restore`, `deletebackup`, `disablereplication`, `enablereplication`, `abortfetch`, `disablepoll`, `enablepoll`, and `fetchindex` require the `update` permission; `indexversion`, `filelist`, `filecontent`, `details`, `commits`, and `restorestatus` require `read`. A command not listed here requires `update`. Grant `update` (or `all`) to roles that invoke the state-changing commands."
diff --git a/changelog/unreleased/SOLR-18228-extract-update-perm.yml b/changelog/unreleased/SOLR-18228-extract-update-perm.yml
new file mode 100644
index 00000000000..4590fcae887
--- /dev/null
+++ b/changelog/unreleased/SOLR-18228-extract-update-perm.yml
@@ -0,0 +1,9 @@
+title: >-
+ /update/extract (ExtractingRequestHandler) now requires the "update"
+ permission rather than "read", since it indexes documents.
+type: changed
+authors:
+ - name: Mark Robert Miller
+links:
+ - name: SOLR-18228
+ url: https://issues.apache.org/jira/browse/SOLR-18228
diff --git a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
index 21d9754a9fd..ddd9793932b 100644
--- a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
@@ -49,6 +49,7 @@ import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
@@ -157,7 +158,23 @@ public class ReplicationHandler extends RequestHandlerBase
@Override
public Name getPermissionName(AuthorizationContext request) {
- return Name.READ_PERM;
+ SolrParams params = request.getParams();
+ String command = params == null ? null : params.get(COMMAND);
+ if (command == null) {
+ return Name.READ_PERM;
+ }
+ switch (command.toLowerCase(Locale.ROOT)) {
+ case CMD_INDEX_VERSION:
+ case CMD_GET_FILE:
+ case CMD_GET_FILE_LIST:
+ case CMD_DETAILS:
+ case CMD_SHOW_COMMITS:
+ case CMD_RESTORE_STATUS:
+ return Name.READ_PERM;
+ default:
+ // State-changing and unknown commands require UPDATE_PERM.
+ return Name.UPDATE_PERM;
+ }
}
private static final class CommitVersionInfo {
diff --git a/solr/core/src/test/org/apache/solr/handler/ReplicationHandlerPermissionNameTest.java b/solr/core/src/test/org/apache/solr/handler/ReplicationHandlerPermissionNameTest.java
new file mode 100644
index 00000000000..c96fe9cdb70
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/ReplicationHandlerPermissionNameTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.solr.handler;
+
+import java.security.Principal;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.params.MapSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.security.AuthorizationContext;
+import org.apache.solr.security.PermissionNameProvider.Name;
+import org.junit.Test;
+
+/**
+ * Unit coverage for {@link ReplicationHandler#getPermissionName(AuthorizationContext)}:
+ * state-changing commands map to {@link Name#UPDATE_PERM}; read-only commands stay on {@link
+ * Name#READ_PERM}.
+ */
+public class ReplicationHandlerPermissionNameTest extends SolrTestCaseJ4 {
+
+ private static Name permFor(ReplicationHandler handler, String command) {
+ return handler.getPermissionName(new FixedParamsAuthorizationContext(command));
+ }
+
+ @Test
+ public void testStateChangingCommandsRequireUpdatePerm() {
+ ReplicationHandler handler = new ReplicationHandler();
+ assertEquals(Name.UPDATE_PERM, permFor(handler, ReplicationHandler.CMD_BACKUP));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, ReplicationHandler.CMD_RESTORE));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, ReplicationHandler.CMD_DELETE_BACKUP));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, ReplicationHandler.CMD_DISABLE_REPL));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, ReplicationHandler.CMD_ENABLE_REPL));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, ReplicationHandler.CMD_ABORT_FETCH));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, ReplicationHandler.CMD_DISABLE_POLL));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, ReplicationHandler.CMD_ENABLE_POLL));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, ReplicationHandler.CMD_FETCH_INDEX));
+ }
+
+ @Test
+ public void testStateChangingCommandsCaseInsensitive() {
+ // handleRequestBody dispatches commands with equalsIgnoreCase; the mapping matches.
+ ReplicationHandler handler = new ReplicationHandler();
+ assertEquals(Name.UPDATE_PERM, permFor(handler, "BACKUP"));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, "Restore"));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, "DeleteBackup"));
+ assertEquals(Name.UPDATE_PERM, permFor(handler, "DisableReplication"));
+ }
+
+ @Test
+ public void testReadOnlyCommandsStayOnReadPerm() {
+ ReplicationHandler handler = new ReplicationHandler();
+ assertEquals(Name.READ_PERM, permFor(handler, ReplicationHandler.CMD_INDEX_VERSION));
+ assertEquals(Name.READ_PERM, permFor(handler, ReplicationHandler.CMD_GET_FILE));
+ assertEquals(Name.READ_PERM, permFor(handler, ReplicationHandler.CMD_GET_FILE_LIST));
+ assertEquals(Name.READ_PERM, permFor(handler, ReplicationHandler.CMD_DETAILS));
+ assertEquals(Name.READ_PERM, permFor(handler, ReplicationHandler.CMD_SHOW_COMMITS));
+ assertEquals(Name.READ_PERM, permFor(handler, ReplicationHandler.CMD_RESTORE_STATUS));
+ }
+
+ @Test
+ public void testUnknownCommandDefaultsToUpdatePerm() {
+ // Unknown commands require UPDATE_PERM, so a future state-changing command is not reachable
+ // with "read".
+ ReplicationHandler handler = new ReplicationHandler();
+ assertEquals(Name.UPDATE_PERM, permFor(handler, "someFutureUnknownCommand"));
+ }
+
+ @Test
+ public void testMissingCommandDefaultsToReadPerm() {
+ // No command parameter means handleRequestBody dispatches nothing; stay on READ_PERM.
+ ReplicationHandler handler = new ReplicationHandler();
+ assertEquals(
+ Name.READ_PERM, handler.getPermissionName(new FixedParamsAuthorizationContext(null)));
+ }
+
+ @Test
+ public void testNullParamsDefaultsToReadPerm() {
+ // HttpSolrCall.AuthorizationContext.getParams() returns null when solrReq is unset; must not
+ // NPE.
+ ReplicationHandler handler = new ReplicationHandler();
+ AuthorizationContext ctx =
+ new FixedParamsAuthorizationContext(null) {
+ @Override
+ public SolrParams getParams() {
+ return null;
+ }
+ };
+ assertEquals(Name.READ_PERM, handler.getPermissionName(ctx));
+ }
+
+ /** Minimal AuthorizationContext that exposes a single {@code command} param. */
+ private static class FixedParamsAuthorizationContext extends AuthorizationContext {
+ private final SolrParams params;
+
+ FixedParamsAuthorizationContext(String command) {
+ this.params =
+ command == null ? SolrParams.of() : new MapSolrParams(Map.of("command", command));
+ }
+
+ @Override
+ public SolrParams getParams() {
+ return params;
+ }
+
+ @Override
+ public Principal getUserPrincipal() {
+ return null;
+ }
+
+ @Override
+ public String getUserName() {
+ return null;
+ }
+
+ @Override
+ public String getHttpHeader(String header) {
+ return null;
+ }
+
+ @Override
+ public Enumeration<String> getHeaderNames() {
+ return null;
+ }
+
+ @Override
+ public String getRemoteAddr() {
+ return null;
+ }
+
+ @Override
+ public String getRemoteHost() {
+ return null;
+ }
+
+ @Override
+ public List<CollectionRequest> getCollectionRequests() {
+ return List.of();
+ }
+
+ @Override
+ public RequestType getRequestType() {
+ return RequestType.UNKNOWN;
+ }
+
+ @Override
+ public String getResource() {
+ return ReplicationHandler.PATH;
+ }
+
+ @Override
+ public String getHttpMethod() {
+ return "GET";
+ }
+
+ @Override
+ public Object getHandler() {
+ return null;
+ }
+ }
+}
diff --git a/solr/core/src/test/org/apache/solr/handler/ReplicationHandlerRecoveryAuthIntegrationTest.java b/solr/core/src/test/org/apache/solr/handler/ReplicationHandlerRecoveryAuthIntegrationTest.java
new file mode 100644
index 00000000000..1e4c4754f3b
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/ReplicationHandlerRecoveryAuthIntegrationTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.solr.handler;
+
+import java.util.concurrent.TimeUnit;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import org.apache.solr.client.solrj.jetty.HttpJettySolrClient;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.QueryRequest;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.client.solrj.response.QueryResponse;
+import org.apache.solr.cloud.SolrCloudAuthTestCase;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.cloud.DocCollection;
+import org.apache.solr.common.cloud.Replica;
+import org.apache.solr.common.cloud.Slice;
+import org.apache.solr.embedded.JettySolrRunner;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Integration test for SOLR-18225: {@code ReplicationHandler.getPermissionName()} now returns
+ * {@code UPDATE_PERM} for state-changing commands including {@code command=fetchindex}, which
+ * SolrCloud follower recovery invokes via {@code RecoveryStrategy → IndexFetcher.fetchLatestIndex}.
+ *
+ * <p><b>Risk this test guards.</b> An operator who locks down {@code security.json} could break
+ * recovery if the gate switch now requires {@code update} on a path internode traffic previously
+ * passed through with {@code read}. The standard operator setup grants {@code all} to the admin
+ * user, and PKI internode traffic carries the calling principal forward. This test exercises that
+ * happy path under auth: the standard admin/{@code all} configuration must still allow follower
+ * recovery to complete.
+ *
+ * <p><b>How recovery is forced.</b> The follower is stopped, a meaningful number of doc updates are
+ * committed on the leader (large enough to push past peer-sync's default threshold so {@code
+ * command=fetchindex} is the recovery path actually exercised), and the follower is restarted. The
+ * test asserts that the follower returns to {@code ACTIVE} and that a {@code distrib=false} query
+ * to the recovered replica returns the full document set — proof that {@code command=fetchindex}
+ * was authorized at the leader and the index transfer completed.
+ */
+public class ReplicationHandlerRecoveryAuthIntegrationTest extends SolrCloudAuthTestCase {
+
+ private static final String COLLECTION = "recovery_auth_test";
+ private static final String SHARD = "shard1";
+ private static final String ADMIN_USER = "solr";
+ private static final String ADMIN_PASS = "SolrRocks";
+
+ // Same hash format and password ("SolrRocks") used by BasicAuthIntegrationTest.STD_CONF.
+ private static final String SECURITY_JSON =
+ "{"
+ + "\"authentication\":{"
+ + " \"blockUnknown\":false,"
+ + " \"class\":\"solr.BasicAuthPlugin\","
+ + " \"credentials\":{\"solr\":\"orwp2Ghgj39lmnrZOTm7Qtre1VqHFDfwAEzr0ApbN3Y= "
+ + "Ju5osoAqOX8iafhWpPP01E5P+sg8tK8tHON7rCYZRRw=\"}"
+ + "},"
+ + "\"authorization\":{"
+ + " \"class\":\"solr.RuleBasedAuthorizationPlugin\","
+ + " \"user-role\":{\"solr\":\"admin\"},"
+ + " \"permissions\":["
+ + " {\"name\":\"all\",\"role\":\"admin\"}"
+ + " ]"
+ + "}"
+ + "}";
+
+ // 200 > peer-sync default ceiling; large enough to push recovery onto the fetchindex path
+ // rather than peer-sync, which is the path SOLR-18225 changed the permission gate on.
+ private static final int DOCS_BEFORE = 50;
+ private static final int DOCS_DURING_DOWNTIME = 200;
+ private static final int TOTAL_EXPECTED = DOCS_BEFORE + DOCS_DURING_DOWNTIME;
+
+ @BeforeClass
+ public static void setupCluster() throws Exception {
+ configureCluster(2)
+ .addConfig("conf", configset("cloud-minimal"))
+ .withSecurityJson(SECURITY_JSON)
+ .configure();
+ CollectionAdminRequest.createCollection(COLLECTION, "conf", 1, 2)
+ .setBasicAuthCredentials(ADMIN_USER, ADMIN_PASS)
+ .process(cluster.getSolrClient());
+ cluster.waitForActiveCollection(COLLECTION, 1, 2);
+ }
+
+ @AfterClass
+ public static void tearDownAfterClass() throws Exception {
+ shutdownCluster();
+ }
+
+ @Test
+ public void testFollowerRecoveryUnderAuth() throws Exception {
+ CloudSolrClient client = cluster.getSolrClient();
+
+ // Index baseline before any node goes down, then commit so both replicas have a stable state.
+ indexDocs(client, "init-", 0, DOCS_BEFORE);
+ commit(client);
+
+ // Identify leader and follower for the single shard.
+ DocCollection coll = client.getClusterState().getCollection(COLLECTION);
+ Slice slice = coll.getSlice(SHARD);
+ Replica leader = slice.getLeader();
+ Replica follower =
+ slice.getReplicas().stream()
+ .filter(r -> !r.getName().equals(leader.getName()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("expected a non-leader replica on " + SHARD));
+ JettySolrRunner followerJetty = jettyFor(follower.getNodeName());
+
+ // Stop the follower; index a substantial volume on the leader so recovery must do a real
+ // fetch (not just peer-sync). command=fetchindex is the path SOLR-18225 changed.
+ followerJetty.stop();
+ cluster.waitForJettyToStop(followerJetty);
+ indexDocs(client, "delta-", 0, DOCS_DURING_DOWNTIME);
+ commit(client);
+
+ // Restart — recovery is triggered. Under SOLR-18225, command=fetchindex now requires
+ // UPDATE_PERM at the leader's authz layer; if PKI internode auth doesn't carry a principal
+ // with update permission, recovery would 403 and the follower would never return to ACTIVE.
+ followerJetty.start();
+ cluster.waitForAllNodes(60);
+ cluster.waitForActiveCollection(COLLECTION, 1, 2);
+
+ // Wait for the recovered replica to report ACTIVE (not RECOVERING/DOWN).
+ cluster
+ .getZkStateReader()
+ .waitForState(
+ COLLECTION,
+ 60,
+ TimeUnit.SECONDS,
+ c -> {
+ if (c == null) return false;
+ Slice s = c.getSlice(SHARD);
+ if (s == null) return false;
+ for (Replica r : s.getReplicas()) {
+ if (r.getState() != Replica.State.ACTIVE) return false;
+ }
+ return true;
+ });
+
+ // Cluster-level query confirms the collection is fully readable. CloudSolrClient does not
+ // forward credentials by default, so wrap in a QueryRequest with explicit auth.
+ SolrQuery q = new SolrQuery("*:*");
+ QueryRequest cluReq = new QueryRequest(q);
+ cluReq.setBasicAuthCredentials(ADMIN_USER, ADMIN_PASS);
+ QueryResponse cluResp = cluReq.process(client, COLLECTION);
+ assertEquals(
+ "cluster-level query must see all docs after recovery",
+ TOTAL_EXPECTED,
+ cluResp.getResults().getNumFound());
+
+ // Direct distrib=false query against the *recovered follower* proves command=fetchindex
+ // was authorized at the leader and the index transfer completed locally on this replica.
+ String followerCoreUrl = followerJetty.getBaseUrl().toString() + "/" + follower.getCoreName();
+ try (HttpJettySolrClient followerClient =
+ new HttpJettySolrClient.Builder(followerCoreUrl)
+ .withBasicAuthCredentials(ADMIN_USER, ADMIN_PASS)
+ .build()) {
+ SolrQuery local = new SolrQuery("*:*");
+ local.set("distrib", "false");
+ QueryResponse localResp = followerClient.query(local);
+ assertEquals(
+ "follower must have replicated every document via fetchindex; "
+ + "if numFound < "
+ + TOTAL_EXPECTED
+ + ", command=fetchindex was likely denied at "
+ + "the leader's authz layer (SOLR-18225 gate change requires UPDATE_PERM)",
+ TOTAL_EXPECTED,
+ localResp.getResults().getNumFound());
+ }
+ }
+
+ private static void indexDocs(CloudSolrClient client, String prefix, int from, int count)
+ throws Exception {
+ UpdateRequest ur = new UpdateRequest();
+ ur.setBasicAuthCredentials(ADMIN_USER, ADMIN_PASS);
+ for (int i = from; i < from + count; i++) {
+ SolrInputDocument doc = new SolrInputDocument();
+ doc.addField("id", prefix + i);
+ ur.add(doc);
+ }
+ ur.process(client, COLLECTION);
+ }
+
+ private static void commit(CloudSolrClient client) throws Exception {
+ UpdateRequest commit = new UpdateRequest();
+ commit.setBasicAuthCredentials(ADMIN_USER, ADMIN_PASS);
+ commit.commit(client, COLLECTION);
+ }
+
+ private static JettySolrRunner jettyFor(String nodeName) {
+ for (JettySolrRunner j : cluster.getJettySolrRunners()) {
+ if (nodeName.equals(j.getNodeName())) return j;
+ }
+ throw new AssertionError("no JettySolrRunner found for node " + nodeName);
+ }
+}
diff --git a/solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java b/solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java
index 374e3fc1e5d..bdc4223f075 100644
--- a/solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java
+++ b/solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java
@@ -311,7 +311,7 @@ public class BaseTestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 {
new ReplicationHandler(),
"collectionRequests",
List.of(new CollectionRequest("mycoll"))),
- STATUS_OK); // Replication requires "READ" permission, which Tim has
+ STATUS_OK); // Replication with no command stays on READ_PERM, which Tim has
checkRules(
Map.of(
@@ -327,6 +327,23 @@ public class BaseTestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 {
List.of(new CollectionRequest("mycoll"))),
FORBIDDEN); // User cio has role 'su' which does not have 'read' permission
+ // Tim holds "read" but not "update"; state-changing replication commands must be rejected.
+ checkRules(
+ Map.of(
+ "resource",
+ ReplicationHandler.PATH,
+ "httpMethod",
+ "POST",
+ "userPrincipal",
+ "tim",
+ "handler",
+ new ReplicationHandler(),
+ "collectionRequests",
+ List.of(new CollectionRequest("mycoll")),
+ "params",
+ new MapSolrParams(Map.of("command", "backup"))),
+ FORBIDDEN);
+
checkRules(
Map.of(
"resource",
@@ -342,6 +359,28 @@ public class BaseTestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 {
STATUS_OK);
}
+ @Test
+ public void testReplicationBackupAllowedWithUpdatePerm() {
+ // Positive counterpart: tim gains "update" via the admin role and can POST command=backup.
+ addPermission("update", "admin");
+
+ checkRules(
+ Map.of(
+ "resource",
+ ReplicationHandler.PATH,
+ "httpMethod",
+ "POST",
+ "userPrincipal",
+ "tim",
+ "handler",
+ new ReplicationHandler(),
+ "collectionRequests",
+ List.of(new CollectionRequest("mycoll")),
+ "params",
+ new MapSolrParams(Map.of("command", "backup"))),
+ STATUS_OK);
+ }
+
@Test
public void testCoreAdminPermissions() {
addPermission("core-admin-edit", "su");
diff --git a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingRequestHandler.java b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingRequestHandler.java
index 3acd11141c7..7cb1aaed73b 100644
--- a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingRequestHandler.java
+++ b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingRequestHandler.java
@@ -46,7 +46,7 @@ public class ExtractingRequestHandler extends ContentStreamHandlerBase
@Override
public PermissionNameProvider.Name getPermissionName(AuthorizationContext request) {
- return PermissionNameProvider.Name.READ_PERM;
+ return PermissionNameProvider.Name.UPDATE_PERM;
}
@Override
diff --git a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerPermissionTest.java b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerPermissionTest.java
new file mode 100644
index 00000000000..e9c723c2a7c
--- /dev/null
+++ b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerPermissionTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.solr.handler.extraction;
+
+import java.io.IOException;
+import java.security.Principal;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.security.AuthorizationContext;
+import org.apache.solr.security.AuthorizationResponse;
+import org.apache.solr.security.PermissionNameProvider;
+import org.apache.solr.security.RuleBasedAuthorizationPlugin;
+import org.apache.solr.security.SimplePrincipal;
+import org.junit.Test;
+
+/**
+ * Permission tests for {@link ExtractingRequestHandler}. It extends {@code
+ * ContentStreamHandlerBase} and indexes documents through {@code ExtractingDocumentLoader}, so it
+ * declares {@code UPDATE_PERM}.
+ */
+public class ExtractingRequestHandlerPermissionTest extends SolrTestCase {
+
+ /**
+ * Fast fail-early sentinel: the handler must declare {@code UPDATE_PERM}. The functional
+ * authorize() tests below also cover this, but this unit-level assertion pinpoints the cause if
+ * {@link ExtractingRequestHandler#getPermissionName} changes.
+ */
+ @Test
+ public void testPermissionNameIsUpdatePerm() {
+ ExtractingRequestHandler handler = new ExtractingRequestHandler();
+ assertEquals(
+ PermissionNameProvider.Name.UPDATE_PERM, handler.getPermissionName(fakeContext("reader")));
+ }
+
+ /** A read-only user cannot write through {@code /update/extract}. */
+ @Test
+ public void testReadOnlyUserDeniedOnUpdateExtract() throws IOException {
+ ExtractingRequestHandler handler = new ExtractingRequestHandler();
+ assertEquals(
+ AuthorizationResponse.FORBIDDEN.statusCode,
+ authorize(authzRules(), fakeContext("reader", "/update/extract", handler)).statusCode);
+ }
+
+ /** A user with {@code update} permission can still use {@code /update/extract} normally. */
+ @Test
+ public void testWriterAllowedOnUpdateExtract() throws IOException {
+ ExtractingRequestHandler handler = new ExtractingRequestHandler();
+ assertEquals(
+ AuthorizationResponse.OK.statusCode,
+ authorize(authzRules(), fakeContext("writer", "/update/extract", handler)).statusCode);
+ }
+
+ /**
+ * Guards against over-restrictiveness: a user with only {@code update} permission and NOT {@code
+ * read} must still be allowed through {@code /update/extract}. {@link
+ * #testWriterAllowedOnUpdateExtract} exercises a user with both {@code read} and {@code update},
+ * so it cannot rule out an accidental coupling that also requires {@code read}. This case proves
+ * the declared {@code UPDATE_PERM} is the only permission the handler requires.
+ */
+ @Test
+ public void testUpdateOnlyUserAllowedOnUpdateExtract() throws IOException {
+ ExtractingRequestHandler handler = new ExtractingRequestHandler();
+ assertEquals(
+ AuthorizationResponse.OK.statusCode,
+ authorize(authzRules(), fakeContext("updater_only", "/update/extract", handler))
+ .statusCode);
+ }
+
+ /**
+ * An unauthenticated caller (null principal) must not be treated as silently authorized on {@code
+ * /update/extract}. Solr's rule-based authorization returns {@code PROMPT} (401) rather than
+ * {@code FORBIDDEN} (403) so the client is asked to authenticate, but either way the request is
+ * not allowed through.
+ */
+ @Test
+ public void testAnonymousUserDeniedOnUpdateExtract() throws IOException {
+ ExtractingRequestHandler handler = new ExtractingRequestHandler();
+ assertEquals(
+ AuthorizationResponse.PROMPT.statusCode,
+ authorize(authzRules(), fakeContext(null, "/update/extract", handler)).statusCode);
+ }
+
+ /**
+ * A user whose role grants the predefined {@code all} permission must still be allowed through
+ * {@code /update/extract}. Guards against a future regression that over-narrows who can use the
+ * handler. Uses a dedicated rule set containing only the {@code all} permission, because {@code
+ * findFirstGoverningPermission} picks the first matching rule in config order — mixing {@code
+ * all} with the {@code update} rule in one set causes {@code update} to govern first and block
+ * any {@code all}-granted user who is not also in the {@code update} role.
+ */
+ @Test
+ public void testSuperUserWithAllPermAllowedOnUpdateExtract() throws IOException {
+ ExtractingRequestHandler handler = new ExtractingRequestHandler();
+ assertEquals(
+ AuthorizationResponse.OK.statusCode,
+ authorize(allPermissionRules(), fakeContext("root", "/update/extract", handler))
+ .statusCode);
+ }
+
+ private static Map<String, Object> authzRules() {
+ String json =
+ "{"
+ + " user-role: { reader: [read-only], writer: [indexer], updater_only: [update-only] },"
+ + " permissions: ["
+ + " {name: read, role: [read-only, indexer]},"
+ + " {name: update, role: [indexer, update-only]}"
+ + " ]"
+ + "}";
+ @SuppressWarnings("unchecked")
+ Map<String, Object> rules = (Map<String, Object>) Utils.fromJSONString(json);
+ return rules;
+ }
+
+ private static Map<String, Object> allPermissionRules() {
+ String json =
+ "{"
+ + " user-role: { root: [admin] },"
+ + " permissions: ["
+ + " {name: all, role: admin}"
+ + " ]"
+ + "}";
+ @SuppressWarnings("unchecked")
+ Map<String, Object> rules = (Map<String, Object>) Utils.fromJSONString(json);
+ return rules;
+ }
+
+ private static AuthorizationResponse authorize(
+ Map<String, Object> rules, AuthorizationContext context) throws IOException {
+ try (RuleBasedAuthorizationPlugin plugin = new RuleBasedAuthorizationPlugin()) {
+ plugin.init(rules);
+ return plugin.authorize(context);
+ }
+ }
+
+ private static AuthorizationContext fakeContext(String user) {
+ return fakeContext(user, "/select", null);
+ }
+
+ private static AuthorizationContext fakeContext(String user, String resource, Object handler) {
+ return new AuthorizationContext() {
+ @Override
+ public SolrParams getParams() {
+ return SolrParams.of();
+ }
+
+ @Override
+ public Principal getUserPrincipal() {
+ return user == null ? null : new SimplePrincipal(user);
+ }
+
+ @Override
+ public String getUserName() {
+ return user;
+ }
+
+ @Override
+ public String getHttpHeader(String header) {
+ return null;
+ }
+
+ @Override
+ public Enumeration<String> getHeaderNames() {
+ return null;
+ }
+
+ @Override
+ public String getRemoteAddr() {
+ return null;
+ }
+
+ @Override
+ public String getRemoteHost() {
+ return null;
+ }
+
+ @Override
+ public List<CollectionRequest> getCollectionRequests() {
+ return List.of(new CollectionRequest("c1"));
+ }
+
+ @Override
+ public RequestType getRequestType() {
+ return RequestType.UNKNOWN;
+ }
+
+ @Override
+ public String getResource() {
+ return resource;
+ }
+
+ @Override
+ public String getHttpMethod() {
+ return "POST";
+ }
+
+ @Override
+ public Object getHandler() {
+ return handler;
+ }
+ };
+ }
+}
diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
index 643800e3181..5a71d6c8bc2 100644
--- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
+++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
@@ -321,6 +321,8 @@ Nowadays, the HTTP request is available via internal APIs: `SolrQueryRequest.get
* `stream.file`, `stream.url`, and `stream.body` params are no longer supported.
+* `/update/extract` (`ExtractingRequestHandler` in the `extraction` module) requires the `update` permission rather than `read`, since it indexes documents. Deployments where `read`-only users submit documents through `/update/extract` must grant those users `update` as well.
+
=== Upgrade to Jetty 12.x and Jakarta namespace
Solr upgraded to Jetty 12.x from 10.x as Jetty 10 and 11 have reached end-of-life support. Jetty 12.x requires Java 17 or newer and is fully compatible with Solr's new minimum requirement of Java 21. This upgrade brings support for modern HTTP protocols and adopts the Jakarta EE 10 namespace. For more details, see https://webtide.com/jetty-12-has-arrived/.
(solr) 04/04: SOLR-18473: Fix CrossDC Consumer NPE on shutdown. (#4932)
Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git
commit 997d8a8b27dd0d70ba86c372f02f43f03d004215
Author: Andrzej Białecki <ab...@apache.org>
AuthorDate: Tue Sep 22 18:46:14 2026 +0200
SOLR-18473: Fix CrossDC Consumer NPE on shutdown. (#4932)
(cherry picked from commit b7850a48cafe39bdd55ca6abae011bfc0911b9c0)
---
.../java/org/apache/solr/response/PrometheusResponseWriter.java | 3 +++
.../org/apache/solr/response/TestPrometheusResponseWriter.java | 9 +++++++++
2 files changed, 12 insertions(+)
diff --git a/solr/core/src/java/org/apache/solr/response/PrometheusResponseWriter.java b/solr/core/src/java/org/apache/solr/response/PrometheusResponseWriter.java
index d56a85b9bbe..f2628bb9240 100644
--- a/solr/core/src/java/org/apache/solr/response/PrometheusResponseWriter.java
+++ b/solr/core/src/java/org/apache/solr/response/PrometheusResponseWriter.java
@@ -85,6 +85,9 @@ public class PrometheusResponseWriter implements QueryResponseWriter {
}
private boolean writeOpenMetricsFormat(SolrQueryRequest request) {
+ if (request == null) {
+ return false;
+ }
String wt = request.getParams().get(CommonParams.WT);
if (MetricUtils.OPEN_METRICS_WT.equals(wt)) {
return true;
diff --git a/solr/core/src/test/org/apache/solr/response/TestPrometheusResponseWriter.java b/solr/core/src/test/org/apache/solr/response/TestPrometheusResponseWriter.java
index 3c6032df129..a3848eb4c3f 100644
--- a/solr/core/src/test/org/apache/solr/response/TestPrometheusResponseWriter.java
+++ b/solr/core/src/test/org/apache/solr/response/TestPrometheusResponseWriter.java
@@ -180,6 +180,15 @@ public class TestPrometheusResponseWriter extends SolrTestCaseJ4 {
}
}
+ @Test
+ public void testNullRequestDefaultsToPrometheusFormat() {
+ PrometheusResponseWriter writer = new PrometheusResponseWriter();
+ assertEquals(
+ "Null request should not NPE and should default to Prometheus format",
+ PrometheusResponseWriter.CONTENT_TYPE_PROMETHEUS,
+ writer.getContentType(null, null));
+ }
+
@Test
public void testUnsupportedMetricsFormat() throws Exception {
var req = new MetricsRequest();
(solr) 03/04: SOLR-18464: Fix three Antora build warnings in the ref guide (#4922)
Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git
commit 3bf8caad6949772e3320e0c8b2185425885c582a
Author: Serhiy Bzhezytskyy <me...@serhiy-bzhezytskyy.com>
AuthorDate: Tue Sep 22 11:52:31 2026 +0300
SOLR-18464: Fix three Antora build warnings in the ref guide (#4922)
(cherry picked from commit 4811a8d3447197720a642b59428b2c51b7b9be8d)
---
.../modules/configuration-guide/pages/configsets-api.adoc | 4 ++--
.../deployment-guide/pages/rule-based-authorization-plugin.adoc | 2 +-
.../indexing-guide/pages/document-enrichment-with-llms.adoc | 8 ++++----
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc
index a8cfecfe430..81d5ff78646 100644
--- a/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc
+++ b/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc
@@ -258,7 +258,7 @@ V2 API::
+
====
With the v2 API, the file path is part of the URL path.
-Use a `PUT` request to `/api/configsets/{configSetName}/files/{filePath}`:
+Use a `PUT` request to `/api/configsets/\{configSetName}/files/\{filePath}`:
[source,bash]
@@ -327,7 +327,7 @@ V2 API::
+
====
With the v2 API, the file path is part of the URL path.
-Use a `GET` request to `/api/configsets/{configSetName}/files/{filePath}`:
+Use a `GET` request to `/api/configsets/\{configSetName}/files/\{filePath}`:
[source,bash]
----
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/rule-based-authorization-plugin.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/rule-based-authorization-plugin.adoc
index 6996a7277f4..241f663f89a 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/rule-based-authorization-plugin.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/rule-based-authorization-plugin.adoc
@@ -405,7 +405,7 @@ If edit permissions should only be applied to specific collections, a custom per
Specifically, the following actions of the Collections API would be allowed:
+
-[.lowpadding,cols="1,1,1",frame=none,grid=none,stripes=none]
+[.lowpadding,cols="1,1,1,1",frame=none,grid=none,stripes=none]
|===
| CREATE
| RELOAD
diff --git a/solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc b/solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc
index 600268187ae..a8b0ac1d257 100644
--- a/solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc
+++ b/solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc
@@ -131,7 +131,7 @@ s|Exactly one of these parameters is required |Default: none
+
Two different ways to define a prompt are available: one directly in the solrconfig and one through a dedicated file.
Either way, the content of the prompt _must_ contain a special token for each `inputField` declared, that are the
-`fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the example below). Solr will throw an error if
+`fieldName` surrounded by curly brackets (e.g., `\{string_field}`, in the example below). Solr will throw an error if
the parameters are not properly defined.
+
These parameters can be defined in one of the following ways:
@@ -144,7 +144,7 @@ These parameters can be defined in one of the following ways:
<processor class="solr.languagemodels.update.processor.factory.DocumentEnrichmentUpdateProcessorFactory">
<str name="inputField">string_field</str>
<str name="outputField">summary</str>
- <str name="prompt">Summarize this content: {string_field}</str>
+ <str name="prompt">Summarize this content: \{string_field}</str>
<str name="model">model-name</str>
</processor>
<processor class="solr.RunUpdateProcessorFactory"/>
@@ -441,7 +441,7 @@ excluding the `DocumentEnrichmentUpdateProcessor` (let's call it 'no-enrichment'
<processor class="solr.languagemodels.update.processor.factory.DocumentEnrichmentUpdateProcessorFactory">
<str name="inputField">string_field</str>
<str name="outputField">summary</str>
- <str name="prompt">Summarize this content: {string_field}</str>
+ <str name="prompt">Summarize this content: \{string_field}</str>
<str name="model">model-name</str>
</processor>
<processor class="solr.RunUpdateProcessorFactory"/>
@@ -485,7 +485,7 @@ You still define two chains, but this time the 'enrichment' one only includes th
<processor class="solr.languagemodels.update.processor.factory.DocumentEnrichmentUpdateProcessorFactory">
<str name="inputField">string_field</str>
<str name="outputField">summary</str>
- <str name="prompt">Summarize this content: {string_field}</str>
+ <str name="prompt">Summarize this content: \{string_field}</str>
<str name="model">model-name</str>
</processor>
<processor class="solr.RunUpdateProcessorFactory"/>