From 83305b4f3201c25ddcf52932f6a6bb1306b1562e Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Thu, 1 Oct 2026 17:45:01 +0000 Subject: [PATCH 01/11] SOLR-13097: Authorize standalone requests against the core name --- changelog/unreleased/SOLR-13097.yml | 7 +++++++ .../java/org/apache/solr/servlet/HttpSolrCall.java | 12 +++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 changelog/unreleased/SOLR-13097.yml diff --git a/changelog/unreleased/SOLR-13097.yml b/changelog/unreleased/SOLR-13097.yml new file mode 100644 index 000000000000..1d39deaa7cca --- /dev/null +++ b/changelog/unreleased/SOLR-13097.yml @@ -0,0 +1,7 @@ +title: Make core-scoped authorization rules work in Solr standalone mode +type: fixed +authors: + - name: Nick Shanin +links: + - name: SOLR-13097 + url: https://issues.apache.org/jira/browse/SOLR-13097 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 35ab076a673d..a38b63a61f77 100644 --- a/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java +++ b/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java @@ -219,13 +219,19 @@ public List getCollectionsList() { /** * 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. + * requests it sends to other collections are authorized by the receiving nodes. In standalone + * mode this is the name of the core serving the request, since there are no collections. + * Otherwise, this is {@link #getCollectionsList()}. Not null. */ public List getAuthorizationCollectionsList() { - if (core == null || !cores.isZooKeeperAware()) { + if (core == null) { return getCollectionsList(); } + if (!cores.isZooKeeperAware()) { + // Standalone mode has no collections; authorize against the serving core's name so that + // core-scoped authorization rules can match. + return List.of(core.getCoreDescriptor().getName()); + } if (coreSelectedForCollection != null) { // e.g. a coordinator's synthetic core, which serves a collection other than its own return List.of(coreSelectedForCollection); From a522cabfd2d01be1575df970c68f5023585bc28f Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Thu, 1 Oct 2026 17:45:01 +0000 Subject: [PATCH 02/11] SOLR-13097: add testing handoff --- SOLR-13097-TESTING.md | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 SOLR-13097-TESTING.md diff --git a/SOLR-13097-TESTING.md b/SOLR-13097-TESTING.md new file mode 100644 index 000000000000..a6461f569823 --- /dev/null +++ b/SOLR-13097-TESTING.md @@ -0,0 +1,47 @@ +# SOLR-13097 — Testing Handoff + +> **UNCOMPILED / UNTESTED.** This patch was written against `apache/solr` main +> at `c3e18f1e455` without running a build or any tests. The reviewer (or CI) +> must compile and validate before merge. + +## What changed + +`solr/core/.../servlet/HttpSolrCall.java` — `getAuthorizationCollectionsList()`: +in standalone (non-ZK) mode with a resolved core, it now returns the serving +core's name instead of the empty `getCollectionsList()`. Collection/core-scoped +`RuleBasedAuthorizationPlugin` rules (e.g. `{"collection":"biblio", ...}`) can +therefore match standalone cores; previously the list was always empty +standalone, so such rules could never match and the auth log showed +`collections: []`. SolrCloud behavior is untouched. + +Also added: `changelog/unreleased/SOLR-13097.yml`. + +## Design note (deviation from research note) + +The research note suggested populating `collectionsList` in `init()`'s +standalone branch. Against current main that would trip `assert +cores.isZooKeeperAware()` in `addCollectionParamIfNeeded()` (called with the +list from both `HttpSolrCall` and `V2HttpCall`) under tests with assertions +enabled. Fixing `getAuthorizationCollectionsList()` directly achieves the +identical user-visible outcome (auth decision + log line both consume it) with +a smaller blast radius; `getCollectionsList()` semantics are unchanged. + +## Suggested reviewer validation + +```bash +./gradlew :solr:core:compileJava -Pvalidation.errorprone=true +./gradlew :solr:core:test --tests "org.apache.solr.security.*" +``` + +Suggested new coverage (not included): standalone-mode test with a +`security.json` containing a `RuleBasedAuthorizationPlugin` with a core-scoped +permission — assert an authorized user can query `/solr//select` and an +unauthorized user is denied; assert `getAuthorizationCollectionsList()` +contains the core name instead of being empty. + +## Limits / risks + +- The core name (not a collection) is what rules must reference standalone — + this matches the reporter's expected fix. +- `core == null` (admin/container paths) still falls back to the empty list. +- No new tests were added in this phase per the contribution workflow. From 7546958a894d4da59202dd57ec6cfbe7ced85c0c Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Thu, 1 Oct 2026 22:47:27 -0600 Subject: [PATCH 03/11] SOLR-13097: regression test for standalone core-scoped authorization --- .../CoreScopedAuthStandaloneTest.java | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java diff --git a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java new file mode 100644 index 000000000000..bca528a31e64 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java @@ -0,0 +1,118 @@ +package org.apache.solr.security; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.solrj.RemoteSolrException; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.request.QueryRequest; +import org.apache.solr.client.solrj.request.SolrQuery; +import org.apache.solr.client.solrj.response.QueryResponse; +import org.apache.solr.embedded.JettySolrRunner; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Verifies that core-scoped authorization rules work in standalone mode (SOLR-13097). In standalone + * mode there are no collections, so the authorization context must carry the serving core's name for + * rules scoped via the "collection" field to match. + */ +public class CoreScopedAuthStandaloneTest extends SolrTestCase { + + private static JettySolrRunner jetty; + + private static final String READER_USER = "reader"; + private static final String READER_PASS = "ReaderPass123"; + private static final String OTHER_USER = "other"; + private static final String OTHER_PASS = "OtherPass123"; + + @BeforeClass + public static void beforeClass() throws Exception { + Path homeDir = createTempDir("corescoped-auth").toAbsolutePath(); + // Create the collection1 core by copying the _default configset; Jetty auto-discovers it. + Path confSrc = Path.of("../../../src/test-files/solr/configsets/_default/conf"); + assertTrue("configset not found at " + confSrc.toAbsolutePath(), Files.isDirectory(confSrc)); + Path coreDir = homeDir.resolve("collection1"); + copyDir(confSrc, coreDir.resolve("conf")); + Files.writeString(coreDir.resolve("core.properties"), "name=collection1\n"); + // Write security.json before startup: basic auth plus a read permission scoped to the + // "collection1" core, granted only to the "corereader" role. + String securityJson = + "{" + + "'authentication':{" + + " 'class':'solr.BasicAuthPlugin'," + + " 'credentials':{" + + " '" + + READER_USER + + "':'" + + Sha256AuthenticationProvider.getSaltedHashedValue(READER_PASS) + + "'," + + " '" + + OTHER_USER + + "':'" + + Sha256AuthenticationProvider.getSaltedHashedValue(OTHER_PASS) + + "'" + + " }}," + + "'authorization':{" + + " 'class':'solr.RuleBasedAuthorizationPlugin'," + + " 'user-role':{'" + + READER_USER + + "':'corereader','" + + OTHER_USER + + "':'otherrole'}," + + " 'permissions':[{'name':'read','role':'corereader','collection':'collection1'}]" + + "}}"; + Files.writeString( + homeDir.resolve("security.json"), securityJson.replace('\'', '"'), StandardCharsets.UTF_8); + jetty = new JettySolrRunner(homeDir.toString(), 0); + jetty.start(); + } + + @AfterClass + public static void afterClass() throws Exception { + if (jetty != null) { + jetty.stop(); + jetty = null; + } + } + + private static void copyDir(Path src, Path dest) throws Exception { + try (var stream = Files.walk(src)) { + for (Path p : (Iterable) stream::iterator) { + Path target = dest.resolve(src.relativize(p).toString()); + if (Files.isDirectory(p)) { + Files.createDirectories(target); + } else { + Files.copy(p, target); + } + } + } + } + + private QueryResponse queryAs(String user, String pass) throws Exception { + SolrClient client = jetty.getSolrClient(); + QueryRequest req = new QueryRequest(new SolrQuery("*:*")); + req.setBasicAuthCredentials(user, pass); + return req.process(client, "collection1"); + } + + @Test + public void testCoreScopedRuleAllowsConfiguredRole() throws Exception { + QueryResponse rsp = queryAs(READER_USER, READER_PASS); + assertEquals(0, rsp.getStatus()); + } + + @Test + public void testCoreScopedRuleDeniesOtherRole() throws Exception { + try { + queryAs(OTHER_USER, OTHER_PASS); + fail("Expected authorization failure for a role without the core-scoped permission"); + } catch (SolrServerException | RemoteSolrException e) { + // expected: 403 Forbidden + assertTrue("Expected 403 but got: " + e.getMessage(), e.getMessage().contains("403")); + } + } +} From 9dd8702905c298ca528eb6b0901cfee62f8f7203 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Thu, 1 Oct 2026 22:49:07 -0600 Subject: [PATCH 04/11] SOLR-13097: add Apache license header to regression test --- .../security/CoreScopedAuthStandaloneTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java index bca528a31e64..6fcabd0dfe5c 100644 --- a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java +++ b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java @@ -1,3 +1,19 @@ +/* + * 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.nio.charset.StandardCharsets; From e8a7543e61b10d4864c673e892526748bd1143de Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Fri, 2 Oct 2026 14:51:42 -0600 Subject: [PATCH 05/11] SOLR-13097: remove TESTING.md handoff file --- SOLR-13097-TESTING.md | 47 ------------------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 SOLR-13097-TESTING.md diff --git a/SOLR-13097-TESTING.md b/SOLR-13097-TESTING.md deleted file mode 100644 index a6461f569823..000000000000 --- a/SOLR-13097-TESTING.md +++ /dev/null @@ -1,47 +0,0 @@ -# SOLR-13097 — Testing Handoff - -> **UNCOMPILED / UNTESTED.** This patch was written against `apache/solr` main -> at `c3e18f1e455` without running a build or any tests. The reviewer (or CI) -> must compile and validate before merge. - -## What changed - -`solr/core/.../servlet/HttpSolrCall.java` — `getAuthorizationCollectionsList()`: -in standalone (non-ZK) mode with a resolved core, it now returns the serving -core's name instead of the empty `getCollectionsList()`. Collection/core-scoped -`RuleBasedAuthorizationPlugin` rules (e.g. `{"collection":"biblio", ...}`) can -therefore match standalone cores; previously the list was always empty -standalone, so such rules could never match and the auth log showed -`collections: []`. SolrCloud behavior is untouched. - -Also added: `changelog/unreleased/SOLR-13097.yml`. - -## Design note (deviation from research note) - -The research note suggested populating `collectionsList` in `init()`'s -standalone branch. Against current main that would trip `assert -cores.isZooKeeperAware()` in `addCollectionParamIfNeeded()` (called with the -list from both `HttpSolrCall` and `V2HttpCall`) under tests with assertions -enabled. Fixing `getAuthorizationCollectionsList()` directly achieves the -identical user-visible outcome (auth decision + log line both consume it) with -a smaller blast radius; `getCollectionsList()` semantics are unchanged. - -## Suggested reviewer validation - -```bash -./gradlew :solr:core:compileJava -Pvalidation.errorprone=true -./gradlew :solr:core:test --tests "org.apache.solr.security.*" -``` - -Suggested new coverage (not included): standalone-mode test with a -`security.json` containing a `RuleBasedAuthorizationPlugin` with a core-scoped -permission — assert an authorized user can query `/solr//select` and an -unauthorized user is denied; assert `getAuthorizationCollectionsList()` -contains the core name instead of being empty. - -## Limits / risks - -- The core name (not a collection) is what rules must reference standalone — - this matches the reporter's expected fix. -- `core == null` (admin/container paths) still falls back to the empty list. -- No new tests were added in this phase per the contribution workflow. From 9adca1a65f10d0f64bc6213e56e054742c900f77 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Fri, 2 Oct 2026 15:00:16 -0600 Subject: [PATCH 06/11] SOLR-13097: tidy formatting for CoreScopedAuthStandaloneTest --- .../apache/solr/security/CoreScopedAuthStandaloneTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java index 6fcabd0dfe5c..2e05f595c63a 100644 --- a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java +++ b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java @@ -33,8 +33,8 @@ /** * Verifies that core-scoped authorization rules work in standalone mode (SOLR-13097). In standalone - * mode there are no collections, so the authorization context must carry the serving core's name for - * rules scoped via the "collection" field to match. + * mode there are no collections, so the authorization context must carry the serving core's name + * for rules scoped via the "collection" field to match. */ public class CoreScopedAuthStandaloneTest extends SolrTestCase { From 65b2753a901c2551388a7deac39b2d9d317f9716 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Fri, 2 Oct 2026 23:58:29 -0400 Subject: [PATCH 07/11] SOLR-13097: two-core and unauthenticated tests, framework configset path, upgrade note and changelog type --- changelog/unreleased/SOLR-13097.yml | 4 +- .../CoreScopedAuthStandaloneTest.java | 120 ++++++++++-------- .../pages/major-changes-in-solr-11.adoc | 8 ++ 3 files changed, 75 insertions(+), 57 deletions(-) diff --git a/changelog/unreleased/SOLR-13097.yml b/changelog/unreleased/SOLR-13097.yml index 1d39deaa7cca..d5eac32b0ecf 100644 --- a/changelog/unreleased/SOLR-13097.yml +++ b/changelog/unreleased/SOLR-13097.yml @@ -1,5 +1,5 @@ -title: Make core-scoped authorization rules work in Solr standalone mode -type: fixed +title: Authorization permissions scoped to a core with the collection field are now enforced in standalone mode, where they were silently ignored +type: changed authors: - name: Nick Shanin links: diff --git a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java index 2e05f595c63a..46bf9f1f977d 100644 --- a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java +++ b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java @@ -19,10 +19,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import org.apache.solr.SolrTestCase; +import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.client.solrj.RemoteSolrException; -import org.apache.solr.client.solrj.SolrClient; -import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.QueryRequest; import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.QueryResponse; @@ -32,57 +32,60 @@ import org.junit.Test; /** - * Verifies that core-scoped authorization rules work in standalone mode (SOLR-13097). In standalone - * mode there are no collections, so the authorization context must carry the serving core's name - * for rules scoped via the "collection" field to match. + * Tests that authorization rules scoped to a core with the "collection" field apply in standalone + * mode, where the core name stands in for the collection. */ public class CoreScopedAuthStandaloneTest extends SolrTestCase { - private static JettySolrRunner jetty; + private static final String CORE_1 = "collection1"; + private static final String CORE_2 = "collection2"; private static final String READER_USER = "reader"; private static final String READER_PASS = "ReaderPass123"; private static final String OTHER_USER = "other"; private static final String OTHER_PASS = "OtherPass123"; + private static JettySolrRunner jetty; + @BeforeClass public static void beforeClass() throws Exception { Path homeDir = createTempDir("corescoped-auth").toAbsolutePath(); - // Create the collection1 core by copying the _default configset; Jetty auto-discovers it. - Path confSrc = Path.of("../../../src/test-files/solr/configsets/_default/conf"); - assertTrue("configset not found at " + confSrc.toAbsolutePath(), Files.isDirectory(confSrc)); - Path coreDir = homeDir.resolve("collection1"); - copyDir(confSrc, coreDir.resolve("conf")); - Files.writeString(coreDir.resolve("core.properties"), "name=collection1\n"); - // Write security.json before startup: basic auth plus a read permission scoped to the - // "collection1" core, granted only to the "corereader" role. + Path confSrc = SolrTestCaseJ4.configset("_default"); + assertTrue("configset not found at " + confSrc, Files.isDirectory(confSrc)); + for (String core : List.of(CORE_1, CORE_2)) { + Path coreDir = homeDir.resolve(core); + copyDir(confSrc, coreDir.resolve("conf")); + Files.writeString(coreDir.resolve("core.properties"), "name=" + core + "\n"); + } + + // each role may read only the core named in its permission String securityJson = - "{" - + "'authentication':{" - + " 'class':'solr.BasicAuthPlugin'," - + " 'credentials':{" - + " '" - + READER_USER - + "':'" - + Sha256AuthenticationProvider.getSaltedHashedValue(READER_PASS) - + "'," - + " '" - + OTHER_USER - + "':'" - + Sha256AuthenticationProvider.getSaltedHashedValue(OTHER_PASS) - + "'" - + " }}," - + "'authorization':{" - + " 'class':'solr.RuleBasedAuthorizationPlugin'," - + " 'user-role':{'" - + READER_USER - + "':'corereader','" - + OTHER_USER - + "':'otherrole'}," - + " 'permissions':[{'name':'read','role':'corereader','collection':'collection1'}]" - + "}}"; - Files.writeString( - homeDir.resolve("security.json"), securityJson.replace('\'', '"'), StandardCharsets.UTF_8); + """ + { + "authentication": { + "class": "solr.BasicAuthPlugin", + "credentials": {"%s": "%s", "%s": "%s"} + }, + "authorization": { + "class": "solr.RuleBasedAuthorizationPlugin", + "user-role": {"%s": "reader-role", "%s": "other-role"}, + "permissions": [ + {"name": "read", "role": "reader-role", "collection": "%s"}, + {"name": "read", "role": "other-role", "collection": "%s"} + ] + } + } + """ + .formatted( + READER_USER, + Sha256AuthenticationProvider.getSaltedHashedValue(READER_PASS), + OTHER_USER, + Sha256AuthenticationProvider.getSaltedHashedValue(OTHER_PASS), + READER_USER, + OTHER_USER, + CORE_1, + CORE_2); + Files.writeString(homeDir.resolve("security.json"), securityJson, StandardCharsets.UTF_8); jetty = new JettySolrRunner(homeDir.toString(), 0); jetty.start(); } @@ -108,27 +111,34 @@ private static void copyDir(Path src, Path dest) throws Exception { } } - private QueryResponse queryAs(String user, String pass) throws Exception { - SolrClient client = jetty.getSolrClient(); + private static QueryResponse queryAs(String user, String pass, String core) throws Exception { QueryRequest req = new QueryRequest(new SolrQuery("*:*")); - req.setBasicAuthCredentials(user, pass); - return req.process(client, "collection1"); + if (user != null) { + req.setBasicAuthCredentials(user, pass); + } + return req.process(jetty.getSolrClient(), core); + } + + private static void assertDenied(String user, String pass, String core, int expectedCode) { + RemoteSolrException e = + expectThrows(RemoteSolrException.class, () -> queryAs(user, pass, core)); + assertEquals(user + " on " + core, expectedCode, e.code()); } @Test - public void testCoreScopedRuleAllowsConfiguredRole() throws Exception { - QueryResponse rsp = queryAs(READER_USER, READER_PASS); - assertEquals(0, rsp.getStatus()); + public void testScopedRuleAllowsItsRoleOnItsCore() throws Exception { + assertEquals(0, queryAs(READER_USER, READER_PASS, CORE_1).getStatus()); + assertEquals(0, queryAs(OTHER_USER, OTHER_PASS, CORE_2).getStatus()); } @Test - public void testCoreScopedRuleDeniesOtherRole() throws Exception { - try { - queryAs(OTHER_USER, OTHER_PASS); - fail("Expected authorization failure for a role without the core-scoped permission"); - } catch (SolrServerException | RemoteSolrException e) { - // expected: 403 Forbidden - assertTrue("Expected 403 but got: " + e.getMessage(), e.getMessage().contains("403")); - } + public void testScopedRuleDeniesOtherRolesOnTheCore() { + assertDenied(OTHER_USER, OTHER_PASS, CORE_1, 403); + assertDenied(READER_USER, READER_PASS, CORE_2, 403); + } + + @Test + public void testRequestWithoutCredentialsIsRejected() { + assertDenied(null, null, CORE_1, 401); } } diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc index ac2de0f81376..760da58f49bb 100644 --- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc +++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc @@ -41,3 +41,11 @@ bin/solr start -Dsolr.node.roles=data:on,overseer:preferred A node started this way asks the Overseer to re-run its node prioritization, so a preferred node takes over without waiting for the current Overseer to restart. Note that node roles are fixed for the lifetime of a node: unlike `ADDROLE`, they cannot be changed on a running node. + +== Security Changes + +=== Core-Scoped Authorization Rules in Standalone Mode + +In standalone mode, authorization permissions that set `collection` to a core name were never applied, so requests to that core were matched only against the permissions that apply to all collections. +These permissions are now enforced, with the name of the core serving the request standing in for the collection. +A permission that was silently ignored before may now deny requests, so review the `collection` values in the permissions of `security.json` before upgrading a standalone installation. From e869956cdf41dd257d4446d39b8fc580783d34b9 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Fri, 2 Oct 2026 23:44:02 -0600 Subject: [PATCH 08/11] SOLR-13097: replace String.formatted with String.format(Locale.ROOT) in test String.formatted uses the default locale and is rejected by the forbiddenApis check, which failed the core check step. --- .../CoreScopedAuthStandaloneTest.java | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java index 46bf9f1f977d..00d791751d58 100644 --- a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java +++ b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java @@ -20,6 +20,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Locale; import org.apache.solr.SolrTestCase; import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.client.solrj.RemoteSolrException; @@ -59,7 +60,7 @@ public static void beforeClass() throws Exception { } // each role may read only the core named in its permission - String securityJson = + String securityJsonTemplate = """ { "authentication": { @@ -75,16 +76,19 @@ public static void beforeClass() throws Exception { ] } } - """ - .formatted( - READER_USER, - Sha256AuthenticationProvider.getSaltedHashedValue(READER_PASS), - OTHER_USER, - Sha256AuthenticationProvider.getSaltedHashedValue(OTHER_PASS), - READER_USER, - OTHER_USER, - CORE_1, - CORE_2); + """; + String securityJson = + String.format( + Locale.ROOT, + securityJsonTemplate, + READER_USER, + Sha256AuthenticationProvider.getSaltedHashedValue(READER_PASS), + OTHER_USER, + Sha256AuthenticationProvider.getSaltedHashedValue(OTHER_PASS), + READER_USER, + OTHER_USER, + CORE_1, + CORE_2); Files.writeString(homeDir.resolve("security.json"), securityJson, StandardCharsets.UTF_8); jetty = new JettySolrRunner(homeDir.toString(), 0); jetty.start(); From 8aa4759770e3c4151d1bef4a96535a15a9942d1e Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Tue, 6 Oct 2026 00:34:10 +0000 Subject: [PATCH 09/11] SOLR-13097: Scope the upgrade note to Solr 10.2 instead of 11 --- .../upgrade-notes/pages/major-changes-in-solr-10.adoc | 8 ++++++++ .../upgrade-notes/pages/major-changes-in-solr-11.adoc | 8 -------- 2 files changed, 8 insertions(+), 8 deletions(-) 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 bd77b60a7730..d4001199100c 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 @@ -34,6 +34,14 @@ This compatibility safeguard can be disabled via the environment variable `SOLR_ Solr 10.0 requires at least Java 21, while SolrJ 10.0 requires at least Java 17. +== Solr 10.2 + +=== Security + +In standalone mode, authorization permissions that set `collection` to a core name were never applied, so requests to that core were matched only against the permissions that apply to all collections. +These permissions are now enforced, with the name of the core serving the request standing in for the collection. +A permission that was silently ignored before may now deny requests, so review the `collection` values in the permissions of `security.json` before upgrading a standalone installation. + == Solr 10.1 === Misc diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc index 760da58f49bb..ac2de0f81376 100644 --- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc +++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc @@ -41,11 +41,3 @@ bin/solr start -Dsolr.node.roles=data:on,overseer:preferred A node started this way asks the Overseer to re-run its node prioritization, so a preferred node takes over without waiting for the current Overseer to restart. Note that node roles are fixed for the lifetime of a node: unlike `ADDROLE`, they cannot be changed on a running node. - -== Security Changes - -=== Core-Scoped Authorization Rules in Standalone Mode - -In standalone mode, authorization permissions that set `collection` to a core name were never applied, so requests to that core were matched only against the permissions that apply to all collections. -These permissions are now enforced, with the name of the core serving the request standing in for the collection. -A permission that was silently ignored before may now deny requests, so review the `collection` values in the permissions of `security.json` before upgrading a standalone installation. From 4c08aa5751ee4c8742c57a3ed8beeac827816611 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Tue, 6 Oct 2026 00:34:11 +0000 Subject: [PATCH 10/11] SOLR-13097: Changelog as a fix, and document core names for the collection field in standalone mode --- changelog/unreleased/SOLR-13097.yml | 4 ++-- .../pages/rule-based-authorization-plugin.adoc | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/changelog/unreleased/SOLR-13097.yml b/changelog/unreleased/SOLR-13097.yml index d5eac32b0ecf..5327500f27c8 100644 --- a/changelog/unreleased/SOLR-13097.yml +++ b/changelog/unreleased/SOLR-13097.yml @@ -1,5 +1,5 @@ -title: Authorization permissions scoped to a core with the collection field are now enforced in standalone mode, where they were silently ignored -type: changed +title: Core-scoped authorization permissions were not applied in standalone mode +type: fixed authors: - name: Nick Shanin links: 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 0d1e0f5c4820..f5c7fc97b2c4 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 @@ -283,6 +283,7 @@ If this name matches a predefined permission, Solr ignores any other properties + Defines the collection(s) the permission applies to. The value can either be a single collection name, or a JSON array containing multiple collections. +In standalone mode there are no collections, and the value can instead be the name of a core; the permission then applies to requests served by that core. + The wildcard `*` is used to indicate that this rule applies to all collections. Similarly the special value `null` can be used to indicate that this permission governs Solr's collection-agnostic ("admin") APIs. From f0e7395f58f3c8d8c9066cd88baa6931d8b6f425 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Wed, 7 Oct 2026 07:21:31 +0000 Subject: [PATCH 11/11] SOLR-13097: Cover the v2 path and wildcard-plus-scoped rules in the standalone test Adds a v2 /api/cores//select case through V2HttpCall and a case pinning how a wildcard permission interacts with core-scoped rules: the scoped rule governs its core on its own, and the wildcard decides only for cores no scoped rule governs. Also states in the ref guide that for a distributed request only the core receiving the request is checked. --- .../CoreScopedAuthStandaloneTest.java | 62 +++++++++++++++++-- .../rule-based-authorization-plugin.adoc | 1 + 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java index 00d791751d58..3af8bddeaca7 100644 --- a/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java +++ b/solr/core/src/test/org/apache/solr/security/CoreScopedAuthStandaloneTest.java @@ -24,9 +24,12 @@ import org.apache.solr.SolrTestCase; import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.client.solrj.RemoteSolrException; +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.request.GenericV2SolrRequest; import org.apache.solr.client.solrj.request.QueryRequest; import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.QueryResponse; +import org.apache.solr.common.util.NamedList; import org.apache.solr.embedded.JettySolrRunner; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -40,11 +43,14 @@ public class CoreScopedAuthStandaloneTest extends SolrTestCase { private static final String CORE_1 = "collection1"; private static final String CORE_2 = "collection2"; + private static final String CORE_3 = "collection3"; private static final String READER_USER = "reader"; private static final String READER_PASS = "ReaderPass123"; private static final String OTHER_USER = "other"; private static final String OTHER_PASS = "OtherPass123"; + private static final String STAR_USER = "star"; + private static final String STAR_PASS = "StarPass123"; private static JettySolrRunner jetty; @@ -53,26 +59,28 @@ public static void beforeClass() throws Exception { Path homeDir = createTempDir("corescoped-auth").toAbsolutePath(); Path confSrc = SolrTestCaseJ4.configset("_default"); assertTrue("configset not found at " + confSrc, Files.isDirectory(confSrc)); - for (String core : List.of(CORE_1, CORE_2)) { + for (String core : List.of(CORE_1, CORE_2, CORE_3)) { Path coreDir = homeDir.resolve(core); copyDir(confSrc, coreDir.resolve("conf")); Files.writeString(coreDir.resolve("core.properties"), "name=" + core + "\n"); } - // each role may read only the core named in its permission + // each scoped role may read only the core named in its permission; the star role holds a + // wildcard permission alongside the scoped ones String securityJsonTemplate = """ { "authentication": { "class": "solr.BasicAuthPlugin", - "credentials": {"%s": "%s", "%s": "%s"} + "credentials": {"%s": "%s", "%s": "%s", "%s": "%s"} }, "authorization": { "class": "solr.RuleBasedAuthorizationPlugin", - "user-role": {"%s": "reader-role", "%s": "other-role"}, + "user-role": {"%s": "reader-role", "%s": "other-role", "%s": "star-role"}, "permissions": [ {"name": "read", "role": "reader-role", "collection": "%s"}, - {"name": "read", "role": "other-role", "collection": "%s"} + {"name": "read", "role": "other-role", "collection": "%s"}, + {"name": "read", "role": "star-role", "collection": "*"} ] } } @@ -85,8 +93,11 @@ public static void beforeClass() throws Exception { Sha256AuthenticationProvider.getSaltedHashedValue(READER_PASS), OTHER_USER, Sha256AuthenticationProvider.getSaltedHashedValue(OTHER_PASS), + STAR_USER, + Sha256AuthenticationProvider.getSaltedHashedValue(STAR_PASS), READER_USER, OTHER_USER, + STAR_USER, CORE_1, CORE_2); Files.writeString(homeDir.resolve("security.json"), securityJson, StandardCharsets.UTF_8); @@ -129,6 +140,30 @@ private static void assertDenied(String user, String pass, String core, int expe assertEquals(user + " on " + core, expectedCode, e.code()); } + private static NamedList queryV2As(String user, String pass, String core) + throws Exception { + GenericV2SolrRequest req = + new GenericV2SolrRequest( + SolrRequest.METHOD.GET, "/cores/" + core + "/select", new SolrQuery("*:*")); + if (user != null) { + req.setBasicAuthCredentials(user, pass); + } + return req.process(jetty.getSolrClient()).getResponse(); + } + + private static void assertV2Allowed(String user, String pass, String core) throws Exception { + NamedList rsp = queryV2As(user, pass, core); + NamedList header = (NamedList) rsp.get("responseHeader"); + assertNotNull("v2 response for " + core + " has no responseHeader", header); + assertEquals(user + " on v2 " + core, 0, header.get("status")); + } + + private static void assertV2Denied(String user, String pass, String core, int expectedCode) { + RemoteSolrException e = + expectThrows(RemoteSolrException.class, () -> queryV2As(user, pass, core)); + assertEquals(user + " on v2 " + core, expectedCode, e.code()); + } + @Test public void testScopedRuleAllowsItsRoleOnItsCore() throws Exception { assertEquals(0, queryAs(READER_USER, READER_PASS, CORE_1).getStatus()); @@ -145,4 +180,21 @@ public void testScopedRuleDeniesOtherRolesOnTheCore() { public void testRequestWithoutCredentialsIsRejected() { assertDenied(null, null, CORE_1, 401); } + + @Test + public void testV2RequestUsesCoreScopedRules() throws Exception { + assertV2Allowed(READER_USER, READER_PASS, CORE_1); + assertV2Denied(OTHER_USER, OTHER_PASS, CORE_1, 403); + assertV2Denied(READER_USER, READER_PASS, CORE_2, 403); + } + + @Test + public void testWildcardRuleAppliesOnlyWhereNoScopedRuleGoverns() throws Exception { + // a scoped rule governs its core on its own; the wildcard rule adds nothing there + assertDenied(STAR_USER, STAR_PASS, CORE_1, 403); + assertDenied(STAR_USER, STAR_PASS, CORE_2, 403); + // on a core no scoped rule governs, the wildcard rule is the one that decides + assertEquals(0, queryAs(STAR_USER, STAR_PASS, CORE_3).getStatus()); + assertDenied(READER_USER, READER_PASS, CORE_3, 403); + } } 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 f5c7fc97b2c4..c4493873699f 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 @@ -284,6 +284,7 @@ If this name matches a predefined permission, Solr ignores any other properties Defines the collection(s) the permission applies to. The value can either be a single collection name, or a JSON array containing multiple collections. In standalone mode there are no collections, and the value can instead be the name of a core; the permission then applies to requests served by that core. +For a distributed request, only the core that receives the request is checked against the permission; the cores hosting the individual shards named in a `shards` parameter are not checked separately. + The wildcard `*` is used to indicate that this rule applies to all collections. Similarly the special value `null` can be used to indicate that this permission governs Solr's collection-agnostic ("admin") APIs.