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/24 15:10:24 UTC
(solr) 02/04: SOLR-18230: Authorize requests against the collection of the core that serves them
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 6c0593099af9411baa02cdefa0748606f364178f
Author: David Smiley <ds...@apache.org>
AuthorDate: Wed Sep 23 22:19:07 2026 -0400
SOLR-18230: Authorize requests against the collection of the core that serves them
In SolrCloud, when a local core serves a request, authorization uses that core's collection rather than the "collection" request parameter. Requests the core sends on to other collections are authorized by the receiving nodes. Routing via the "collection" parameter is unchanged. A coordinator node authorizes against the collection it serves.
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
(cherry picked from commit b06b1510c3ee4e715893705dba8e4dc8073b29bb)
---
...-18230-authorize-dispatched-core-collection.yml | 13 ++
.../src/java/org/apache/solr/api/V2HttpCall.java | 3 +-
.../java/org/apache/solr/servlet/HttpSolrCall.java | 24 ++-
.../RuleBasedAuthorizationCoreCollectionTest.java | 201 +++++++++++++++++++++
4 files changed, 239 insertions(+), 2 deletions(-)
diff --git a/changelog/unreleased/SOLR-18230-authorize-dispatched-core-collection.yml b/changelog/unreleased/SOLR-18230-authorize-dispatched-core-collection.yml
new file mode 100644
index 00000000000..e280c500bb3
--- /dev/null
+++ b/changelog/unreleased/SOLR-18230-authorize-dispatched-core-collection.yml
@@ -0,0 +1,13 @@
+# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
+title: >-
+ In SolrCloud, requests are authorized against the collection of the core that serves them;
+ the "collection" request parameter is not used for authorization.
+type: changed
+authors:
+ - name: David Smiley
+ - name: Mark Robert Miller
+links:
+ - name: SOLR-18230
+ url: https://issues.apache.org/jira/browse/SOLR-18230
+important_notes:
+ - "In SolrCloud, a request served by a core on the receiving node is authorized against that core's collection, regardless of the `collection` request parameter. Requests the core sends on to other collections, such as a distributed search over several collections or an alias, are authorized by the nodes that receive them, against their own collections. For example, a search sent to a core of collection `A` with `collection=B` needs read permission on both `A` and `B`."
diff --git a/solr/core/src/java/org/apache/solr/api/V2HttpCall.java b/solr/core/src/java/org/apache/solr/api/V2HttpCall.java
index df88f992e8e..8aac0682e1c 100644
--- a/solr/core/src/java/org/apache/solr/api/V2HttpCall.java
+++ b/solr/core/src/java/org/apache/solr/api/V2HttpCall.java
@@ -331,7 +331,8 @@ public class V2HttpCall extends HttpSolrCall {
containerRequest.setProperty(RequestContextKeys.HTTP_SERVLET_REQ, req);
containerRequest.setProperty(RequestContextKeys.REQUEST_TYPE, requestType);
containerRequest.setProperty(RequestContextKeys.SOLR_PARAMS, queryParams);
- containerRequest.setProperty(RequestContextKeys.COLLECTION_LIST, collectionsList);
+ containerRequest.setProperty(
+ RequestContextKeys.COLLECTION_LIST, getAuthorizationCollectionsList());
containerRequest.setProperty(RequestContextKeys.HTTP_SERVLET_RSP, response);
if (core != null) {
containerRequest.setProperty(RequestContextKeys.SOLR_CORE, core);
diff --git a/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java b/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java
index 0002e917d46..35ab076a673 100644
--- a/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java
+++ b/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java
@@ -138,6 +138,8 @@ public class HttpSolrCall {
protected String origCorename;
// The list of SolrCloud collections if in SolrCloud (usually 1)
protected List<String> collectionsList;
+ // The collection passed to getCoreByCollection, if that is how the core was selected
+ private String coreSelectedForCollection;
protected RequestType requestType;
@@ -214,6 +216,24 @@ public class HttpSolrCall {
return collectionsList != null ? collectionsList : List.of();
}
+ /**
+ * The collection(s) to authorize this request against. In SolrCloud, when a local core serves the
+ * request, this is the collection of that core, since that is where the request executes;
+ * requests it sends to other collections are authorized by the receiving nodes. Otherwise, this
+ * is {@link #getCollectionsList()}. Not null.
+ */
+ public List<String> getAuthorizationCollectionsList() {
+ if (core == null || !cores.isZooKeeperAware()) {
+ return getCollectionsList();
+ }
+ if (coreSelectedForCollection != null) {
+ // e.g. a coordinator's synthetic core, which serves a collection other than its own
+ return List.of(coreSelectedForCollection);
+ }
+ String collection = core.getCoreDescriptor().getCollectionName();
+ return collection != null ? List.of(collection) : getCollectionsList();
+ }
+
@SuppressForbidden(
reason =
"Set the thread contextClassLoader for all 3rd party dependencies that we cannot control")
@@ -853,6 +873,7 @@ public class HttpSolrCall {
* force update collection if it is not found in local cluster state
*/
protected SolrCore getCoreByCollection(String collectionName, boolean isPreferLeader) {
+ coreSelectedForCollection = collectionName;
ZkStateReader zkStateReader = cores.getZkController().getZkStateReader();
ClusterState clusterState = zkStateReader.getClusterState();
DocCollection collection = resolveDocCollection(collectionName);
@@ -1016,7 +1037,8 @@ public class HttpSolrCall {
String resource = getPath();
final List<CollectionRequest> collectionRequests =
- AuthorizationUtils.getCollectionRequests(getPath(), getCollectionsList(), getQueryParams());
+ AuthorizationUtils.getCollectionRequests(
+ getPath(), getAuthorizationCollectionsList(), getQueryParams());
// Populate the request type if the request is select or update
if (requestType == RequestType.UNKNOWN) {
diff --git a/solr/core/src/test/org/apache/solr/security/RuleBasedAuthorizationCoreCollectionTest.java b/solr/core/src/test/org/apache/solr/security/RuleBasedAuthorizationCoreCollectionTest.java
new file mode 100644
index 00000000000..01a7fe69071
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/security/RuleBasedAuthorizationCoreCollectionTest.java
@@ -0,0 +1,201 @@
+/*
+ * 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.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.QueryRequest;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.cloud.SolrCloudAuthTestCase;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.core.NodeRoles;
+import org.apache.solr.embedded.JettySolrRunner;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * In SolrCloud, a request is authorized against the collection of the core that serves it, not the
+ * {@code collection} request parameter. Collections reached from there (distributed search) are
+ * authorized by the receiving nodes.
+ */
+@SolrTestCaseJ4.SuppressSSL
+public class RuleBasedAuthorizationCoreCollectionTest extends SolrCloudAuthTestCase {
+
+ private static final String PUBLIC_COLL = "publicColl";
+ private static final String PRIVATE_COLL = "privateColl";
+ private static final String BOTH_ALIAS = "bothAlias";
+ // Indexed into PRIVATE_COLL; asserting its absence from a response body is independent of status
+ private static final String PRIVATE_DOC_MARKER = "marker_8f3a2c";
+
+ private static final String PASSWORD = "SolrRocks";
+ private static final String ADMIN = "solr";
+ private static final String PUBLIC_READER = "publicReader";
+ private static final String BOTH_READER = "bothReader";
+
+ // Every user's password is 'SolrRocks', same hash as BasicAuthIntegrationTest.
+ private static final String PWD_HASH =
+ "orwp2Ghgj39lmnrZOTm7Qtre1VqHFDfwAEzr0ApbN3Y= Ju5osoAqOX8iafhWpPP01E5P+sg8tK8tHON7rCYZRRw=";
+ private static final String SECURITY_JSON =
+ ("{"
+ + " 'authentication':{"
+ + " 'blockUnknown':'true',"
+ + " 'class':'solr.BasicAuthPlugin',"
+ + " 'credentials':{"
+ + " 'solr':'PWD', 'publicReader':'PWD', 'bothReader':'PWD'"
+ + " }"
+ + " },"
+ + " 'authorization':{"
+ + " 'class':'solr.RuleBasedAuthorizationPlugin',"
+ + " 'user-role':{"
+ + " 'solr':'admin',"
+ + " 'publicReader':'publicRead',"
+ + " 'bothReader':['publicRead','privateRead']"
+ + " },"
+ + " 'permissions':["
+ + " {'name':'read', 'collection':'publicColl', 'role':['publicRead','admin']},"
+ + " {'name':'read', 'collection':'privateColl', 'role':['privateRead','admin']},"
+ + " {'name':'all', 'role':'admin'}"
+ + " ]"
+ + " }"
+ + "}")
+ .replace("PWD", PWD_HASH)
+ .replace('\'', '"');
+
+ @Before
+ public void setupCluster() throws Exception {
+ configureCluster(1)
+ .addConfig("conf", configset("cloud-minimal"))
+ .withSecurityJson(SECURITY_JSON)
+ .configure();
+ for (String coll : new String[] {PUBLIC_COLL, PRIVATE_COLL}) {
+ CollectionAdminRequest.createCollection(coll, "conf", 1, 1)
+ .setBasicAuthCredentials(ADMIN, PASSWORD)
+ .process(cluster.getSolrClient());
+ cluster.waitForActiveCollection(coll, 1, 1);
+ }
+ CollectionAdminRequest.createAlias(BOTH_ALIAS, PUBLIC_COLL + "," + PRIVATE_COLL)
+ .setBasicAuthCredentials(ADMIN, PASSWORD)
+ .process(cluster.getSolrClient());
+
+ addDoc(PUBLIC_COLL, "pub-1", "public");
+ addDoc(PRIVATE_COLL, "priv-1", PRIVATE_DOC_MARKER);
+ }
+
+ @After
+ public void tearDownCluster() throws Exception {
+ shutdownCluster();
+ }
+
+ private void addDoc(String collection, String id, String title) throws Exception {
+ SolrInputDocument doc = new SolrInputDocument();
+ doc.addField("id", id);
+ doc.addField("title_s", title);
+ UpdateRequest update = new UpdateRequest();
+ update.add(doc).setBasicAuthCredentials(ADMIN, PASSWORD);
+ update.commit(cluster.getSolrClient(), collection);
+ }
+
+ @Test
+ public void testSearchAcrossPermittedCollections() throws Exception {
+ Response rsp =
+ request(
+ "/" + coreName(PUBLIC_COLL) + "/select?collection=" + BOTH_ALIAS + "&q=*:*",
+ BOTH_READER);
+ assertEquals(rsp.body, 200, rsp.status);
+ assertTrue(rsp.body, rsp.body.contains(PRIVATE_DOC_MARKER));
+
+ for (String collection : new String[] {BOTH_ALIAS, PUBLIC_COLL + "," + PRIVATE_COLL}) {
+ ModifiableSolrParams params = new ModifiableSolrParams();
+ params.set("q", "*:*");
+ params.set("collection", collection);
+ QueryRequest query = new QueryRequest(params, SolrRequest.METHOD.GET);
+ query.setBasicAuthCredentials(BOTH_READER, PASSWORD);
+ assertEquals(
+ collection,
+ 2,
+ query.process(cluster.getSolrClient(), PUBLIC_COLL).getResults().getNumFound());
+ }
+ }
+
+ @Test
+ public void testCoordinatorAuthorizesTargetCollection() throws Exception {
+ JettySolrRunner coordinator;
+ System.setProperty(NodeRoles.NODE_ROLES_PROP, "coordinator:on");
+ try {
+ coordinator = cluster.startJettySolrRunner();
+ } finally {
+ System.clearProperty(NodeRoles.NODE_ROLES_PROP);
+ }
+ String baseUrl = coordinator.getBaseUrl().toString();
+
+ Response pub = request(baseUrl, "/" + PUBLIC_COLL + "/select?q=*:*", PUBLIC_READER);
+ assertEquals(pub.body, 200, pub.status);
+ assertRefused(request(baseUrl, "/" + PRIVATE_COLL + "/select?q=*:*", PUBLIC_READER));
+ Response priv = request(baseUrl, "/" + PRIVATE_COLL + "/select?q=*:*", BOTH_READER);
+ assertEquals(priv.body, 200, priv.status);
+ assertTrue(priv.body, priv.body.contains(PRIVATE_DOC_MARKER));
+ }
+
+ private static void assertRefused(Response rsp) {
+ assertFalse(rsp.body, rsp.body.contains(PRIVATE_DOC_MARKER));
+ assertTrue(
+ "Expected 401/403, got " + rsp.status + ": " + rsp.body,
+ rsp.status == HttpURLConnection.HTTP_UNAUTHORIZED
+ || rsp.status == HttpURLConnection.HTTP_FORBIDDEN);
+ }
+
+ private String coreName(String collection) {
+ return cluster.getJettySolrRunner(0).getCoreContainer().getAllCoreNames().stream()
+ .filter(n -> n.startsWith(collection + "_"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no core of " + collection + " on node 0"));
+ }
+
+ private record Response(int status, String body) {}
+
+ private Response request(String pathAndQuery, String user) throws Exception {
+ return request(cluster.getJettySolrRunner(0).getBaseUrl().toString(), pathAndQuery, user);
+ }
+
+ private static Response request(String baseUrl, String pathAndQuery, String user)
+ throws Exception {
+ URI uri = URI.create(baseUrl + pathAndQuery + "&wt=json");
+ HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection();
+ try {
+ String credentials = user + ":" + PASSWORD;
+ conn.setRequestProperty(
+ "Authorization",
+ "Basic "
+ + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)));
+ int status = conn.getResponseCode();
+ InputStream in = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
+ String rspBody = in == null ? "" : new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ return new Response(status, rspBody);
+ } finally {
+ conn.disconnect();
+ }
+ }
+}