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:13 UTC
(solr) 02/04: SOLR-18225, SOLR-18228: Authorize ReplicationHandler and /update/extract per command
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/.