From fc61eaf5f69211b121ace37cec7ba95d25d7ab46 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 16 Sep 2026 06:04:46 -0400 Subject: [PATCH 1/5] Add resource-oriented v2 APIs for Basic Auth users and RBAC roles/permissions Solr's existing v2 security endpoints are command-batch-over-POST (the same "set-user"/"set-permission"/etc. commands as v1, just reachable at a v2 URL) rather than true resource-oriented REST. This adds a genuine v2 surface alongside it, following the same JAX-RS/Jersey pattern already used by ClusterPropertyApis/AliasPropertyApis: - GET/PUT/DELETE /api/cluster/security/authentication/{scheme}/users/{username} - GET/PUT/DELETE /api/cluster/security/authorization/{scheme}/roles/{username} - GET/POST/PUT/DELETE /api/cluster/security/authorization/permissions/{index} The {scheme} segment lets these APIs work under MultiAuthPlugin/ MultiAuthRuleBasedAuthorizationPlugin (routing to the right sub-plugin), and is simply ignored for a plain BasicAuthPlugin/RuleBasedAuthorizationPlugin setup. Permissions have no such segment - MultiAuthRuleBasedAuthorizationPlugin already treats *-permission commands as shared across every scheme. All mutations still funnel through SecurityConfHandler's existing CommandOperation/ConfigEditablePlugin machinery (extracted here into a new public editSecurityConfig() shared with the legacy v1/v2 command endpoints), so this is additive REST surface, not a rewrite of the security model. Also wires the Admin UI's Security screen to the new endpoints (falling back to nothing now needed - the scheme parameter means the same call path works for both plain and multi-auth setups), and documents the new endpoints in the ref guide. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Kapco1bnqFBMkFHFPF6MFL --- .../api/endpoint/AuthenticationUsersApi.java | 82 +++++ .../endpoint/AuthorizationPermissionsApi.java | 81 +++++ .../api/endpoint/AuthorizationRolesApi.java | 88 +++++ .../api/model/CreatePermissionResponse.java | 27 ++ .../api/model/GetUserRolesResponse.java | 28 ++ .../api/model/ListPermissionsResponse.java | 28 ++ .../client/api/model/ListUsersResponse.java | 28 ++ .../api/model/PermissionDefinition.java | 61 ++++ .../client/api/model/PermissionDetails.java | 28 ++ .../client/api/model/SetUserRequestBody.java | 27 ++ .../api/model/SetUserRolesRequestBody.java | 28 ++ .../org/apache/solr/core/CoreContainer.java | 4 + .../handler/admin/SecurityConfHandler.java | 51 ++- .../solr/handler/admin/api/Permissions.java | 228 ++++++++++++ .../apache/solr/handler/admin/api/Roles.java | 167 +++++++++ .../apache/solr/handler/admin/api/Users.java | 156 ++++++++ .../MultiAuthUsersAndRolesApiCloudTest.java | 172 +++++++++ .../admin/api/SecurityV2ApiCloudTest.java | 127 +++++++ .../api/SecurityV2ApiStandaloneTest.java | 190 ++++++++++ .../pages/basic-authentication-plugin.adoc | 90 +++++ .../rule-based-authorization-plugin.adoc | 344 +++++++++++++++++- .../web/js/angular/controllers/security.js | 84 +++-- solr/webapp/web/js/angular/services.js | 12 + 23 files changed, 2072 insertions(+), 59 deletions(-) create mode 100644 solr/api/src/java/org/apache/solr/client/api/endpoint/AuthenticationUsersApi.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationPermissionsApi.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationRolesApi.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/CreatePermissionResponse.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/GetUserRolesResponse.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/ListPermissionsResponse.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/ListUsersResponse.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/PermissionDefinition.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/PermissionDetails.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SetUserRequestBody.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SetUserRolesRequestBody.java create mode 100644 solr/core/src/java/org/apache/solr/handler/admin/api/Permissions.java create mode 100644 solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java create mode 100644 solr/core/src/java/org/apache/solr/handler/admin/api/Users.java create mode 100644 solr/core/src/test/org/apache/solr/handler/admin/api/MultiAuthUsersAndRolesApiCloudTest.java create mode 100644 solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java create mode 100644 solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiStandaloneTest.java diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthenticationUsersApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthenticationUsersApi.java new file mode 100644 index 000000000000..17cd05ae64ee --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthenticationUsersApi.java @@ -0,0 +1,82 @@ +/* + * 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.client.api.endpoint; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import org.apache.solr.client.api.model.ListUsersResponse; +import org.apache.solr.client.api.model.SetUserRequestBody; +import org.apache.solr.client.api.model.SolrJerseyResponse; + +/** + * Definitions for v2 JAX-RS APIs managing Basic Authentication users. + * + *

These APIs are a resource-oriented alternative to the "set-user"/"delete-user" commands + * accepted by the {@code /cluster/security/authentication} API - both operate on the same + * underlying plugin configuration. + * + *

The {@code scheme} path segment names the authentication scheme these users belong to (e.g. + * "basic"), as configured under {@code MultiAuthPlugin}'s "schemes" list. It is ignored when {@code + * MultiAuthPlugin} isn't in use - a plain {@code BasicAuthPlugin} setup has only one set of users, + * and any value may be supplied (conventionally "basic"). + */ +@Path("/cluster/security/authentication/{scheme}/users") +public interface AuthenticationUsersApi { + @GET + @Operation( + summary = "List the usernames configured for Basic Authentication.", + tags = {"authentication"}) + ListUsersResponse listUsers( + @Parameter(description = "The authentication scheme these users belong to.", required = true) + @PathParam("scheme") + String scheme); + + @PUT + @Path("/{username}") + @Operation( + summary = "Create a new user, or change an existing user's password.", + tags = {"authentication"}) + SolrJerseyResponse createOrUpdateUser( + @Parameter(description = "The authentication scheme this user belongs to.", required = true) + @PathParam("scheme") + String scheme, + @Parameter(description = "The username to create or update.", required = true) + @PathParam("username") + String username, + @RequestBody(description = "The new password for this user.", required = true) + SetUserRequestBody requestBody) + throws Exception; + + @DELETE + @Path("/{username}") + @Operation( + summary = "Delete a Basic Authentication user.", + tags = {"authentication"}) + SolrJerseyResponse deleteUser( + @Parameter(description = "The authentication scheme this user belongs to.", required = true) + @PathParam("scheme") + String scheme, + @Parameter(description = "The username to delete.", required = true) @PathParam("username") + String username) + throws Exception; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationPermissionsApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationPermissionsApi.java new file mode 100644 index 000000000000..0e941dcff12e --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationPermissionsApi.java @@ -0,0 +1,81 @@ +/* + * 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.client.api.endpoint; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import org.apache.solr.client.api.model.CreatePermissionResponse; +import org.apache.solr.client.api.model.ListPermissionsResponse; +import org.apache.solr.client.api.model.PermissionDefinition; +import org.apache.solr.client.api.model.SolrJerseyResponse; + +/** + * Definitions for v2 JAX-RS APIs managing Rule-Based Authorization permissions. + * + *

Resource-oriented alternative to the {@code set-permission}/{@code update-permission}/{@code + * delete-permission} commands accepted by the {@code /cluster/security/authorization} API. A + * permission's {@code index} - its position in the evaluated-top-down list - moves from a body + * field to a path parameter. + */ +@Path("/cluster/security/authorization/permissions") +public interface AuthorizationPermissionsApi { + @GET + @Operation( + summary = "List the configured permissions, in evaluation order.", + tags = {"authorization"}) + ListPermissionsResponse listPermissions(); + + @POST + @Operation( + summary = "Create a new permission.", + tags = {"authorization"}) + CreatePermissionResponse createPermission( + @RequestBody(description = "The permission to create.", required = true) + PermissionDefinition requestBody) + throws Exception; + + @PUT + @Path("/{index}") + @Operation( + summary = "Update an existing permission.", + tags = {"authorization"}) + SolrJerseyResponse updatePermission( + @Parameter(description = "The index of the permission to update.", required = true) + @PathParam("index") + int index, + @RequestBody(description = "The fields to update.", required = true) + PermissionDefinition requestBody) + throws Exception; + + @DELETE + @Path("/{index}") + @Operation( + summary = "Delete a permission.", + tags = {"authorization"}) + SolrJerseyResponse deletePermission( + @Parameter(description = "The index of the permission to delete.", required = true) + @PathParam("index") + int index) + throws Exception; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationRolesApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationRolesApi.java new file mode 100644 index 000000000000..b1a539aebf97 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationRolesApi.java @@ -0,0 +1,88 @@ +/* + * 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.client.api.endpoint; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import org.apache.solr.client.api.model.GetUserRolesResponse; +import org.apache.solr.client.api.model.SetUserRolesRequestBody; +import org.apache.solr.client.api.model.SolrJerseyResponse; + +/** + * Definitions for v2 JAX-RS APIs mapping roles to users under Rule-Based Authorization. + * + *

Resource-oriented alternative to the {@code set-user-role} command accepted by the {@code + * /cluster/security/authorization} API. A {@code DELETE} replaces that command's {@code null} value + * idiom for revoking a user's roles. + * + *

The {@code scheme} path segment names the authentication scheme these role mappings belong to + * (e.g. "basic"), as configured under {@code MultiAuthRuleBasedAuthorizationPlugin}'s "schemes" + * list. It is ignored when that plugin isn't in use - a plain {@code RuleBasedAuthorizationPlugin} + * setup has only one set of role mappings, and any value may be supplied (conventionally "basic"). + * Unlike roles, permissions are shared across every scheme, so {@link AuthorizationPermissionsApi} + * has no such segment. + */ +@Path("/cluster/security/authorization/{scheme}/roles") +public interface AuthorizationRolesApi { + @GET + @Path("/{username}") + @Operation( + summary = "Get the roles assigned to a user.", + tags = {"authorization"}) + GetUserRolesResponse getUserRoles( + @Parameter(description = "The authentication scheme this user belongs to.", required = true) + @PathParam("scheme") + String scheme, + @Parameter(description = "The username to look up.", required = true) @PathParam("username") + String username); + + @PUT + @Path("/{username}") + @Operation( + summary = "Assign roles to a user, replacing any roles it already has.", + tags = {"authorization"}) + SolrJerseyResponse setUserRoles( + @Parameter(description = "The authentication scheme this user belongs to.", required = true) + @PathParam("scheme") + String scheme, + @Parameter(description = "The username to assign roles to.", required = true) + @PathParam("username") + String username, + @RequestBody(description = "The roles to assign.", required = true) + SetUserRolesRequestBody requestBody) + throws Exception; + + @DELETE + @Path("/{username}") + @Operation( + summary = "Revoke all roles from a user.", + tags = {"authorization"}) + SolrJerseyResponse deleteUserRoles( + @Parameter(description = "The authentication scheme this user belongs to.", required = true) + @PathParam("scheme") + String scheme, + @Parameter(description = "The username to revoke roles from.", required = true) + @PathParam("username") + String username) + throws Exception; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/CreatePermissionResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/CreatePermissionResponse.java new file mode 100644 index 000000000000..277f3966eb07 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/CreatePermissionResponse.java @@ -0,0 +1,27 @@ +/* + * 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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +public class CreatePermissionResponse extends SolrJerseyResponse { + @Schema(description = "The index assigned to the newly created permission.") + @JsonProperty("index") + public Integer index; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/GetUserRolesResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/GetUserRolesResponse.java new file mode 100644 index 000000000000..ccd88091d75b --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/GetUserRolesResponse.java @@ -0,0 +1,28 @@ +/* + * 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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public class GetUserRolesResponse extends SolrJerseyResponse { + @Schema(description = "The roles assigned to this user. Empty if the user has none.") + @JsonProperty("roles") + public List roles; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/ListPermissionsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/ListPermissionsResponse.java new file mode 100644 index 000000000000..0220a0955a10 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/ListPermissionsResponse.java @@ -0,0 +1,28 @@ +/* + * 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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public class ListPermissionsResponse extends SolrJerseyResponse { + @Schema(description = "The configured permissions, in evaluation order.") + @JsonProperty("permissions") + public List permissions; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/ListUsersResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/ListUsersResponse.java new file mode 100644 index 000000000000..a5c3b871063f --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/ListUsersResponse.java @@ -0,0 +1,28 @@ +/* + * 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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public class ListUsersResponse extends SolrJerseyResponse { + @Schema(description = "The configured usernames. Never includes password hashes.") + @JsonProperty("users") + public List users; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/PermissionDefinition.java b/solr/api/src/java/org/apache/solr/client/api/model/PermissionDefinition.java new file mode 100644 index 000000000000..cc959ae8c2d8 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/PermissionDefinition.java @@ -0,0 +1,61 @@ +/* + * 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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Map; + +/** The fields of a Rule-Based Authorization permission, as created or updated by a caller. */ +public class PermissionDefinition { + @Schema(description = "The name of a predefined permission, e.g. 'read', 'update', 'all'.") + @JsonProperty("name") + public String name; + + @Schema(description = "The role(s) this permission is granted to.") + @JsonProperty("role") + public List role; + + @Schema( + description = + "The collection(s) this permission applies to. Omit for collection-agnostic requests" + + " (e.g. the Collections API); use an explicit null element to mean 'no" + + " collection'.") + @JsonProperty("collection") + public List collection; + + @Schema(description = "The request path(s) this permission applies to.") + @JsonProperty("path") + public List path; + + @Schema(description = "The HTTP method(s) this permission applies to.") + @JsonProperty("method") + public List method; + + @Schema(description = "Request parameter values this permission is restricted to matching.") + @JsonProperty("params") + public Map params; + + @Schema( + description = + "On creation only: place the new permission immediately before the permission " + + "currently at this index, instead of appending it at the end.") + @JsonProperty("before") + public Integer before; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/PermissionDetails.java b/solr/api/src/java/org/apache/solr/client/api/model/PermissionDetails.java new file mode 100644 index 000000000000..497f2e9bafba --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/PermissionDetails.java @@ -0,0 +1,28 @@ +/* + * 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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +/** A permission as it exists in {@code security.json}, including its current list position. */ +public class PermissionDetails extends PermissionDefinition { + @Schema(description = "This permission's current position in the evaluated-top-down list.") + @JsonProperty("index") + public Integer index; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SetUserRequestBody.java b/solr/api/src/java/org/apache/solr/client/api/model/SetUserRequestBody.java new file mode 100644 index 000000000000..f7d720bc9b8a --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SetUserRequestBody.java @@ -0,0 +1,27 @@ +/* + * 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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +public class SetUserRequestBody { + @Schema(description = "The new password for this user.") + @JsonProperty("password") + public String password; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SetUserRolesRequestBody.java b/solr/api/src/java/org/apache/solr/client/api/model/SetUserRolesRequestBody.java new file mode 100644 index 000000000000..4085d15113e1 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SetUserRolesRequestBody.java @@ -0,0 +1,28 @@ +/* + * 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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public class SetUserRolesRequestBody { + @Schema(description = "The roles this user should have, replacing any it already has.") + @JsonProperty("roles") + public List roles; +} 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 64916561070a..cb92596b3225 100644 --- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java +++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java @@ -2381,6 +2381,10 @@ public AuthenticationPlugin getAuthenticationPlugin() { return authenticationPlugin == null ? null : authenticationPlugin.plugin; } + public SecurityConfHandler getSecurityConfHandler() { + return securityConfHandler; + } + public AuditLoggerPlugin getAuditLoggerPlugin() { return auditloggerPlugin == null ? null : auditloggerPlugin.plugin; } diff --git a/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java index 353e096d2948..c287d83f19c6 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java @@ -32,6 +32,7 @@ import org.apache.solr.api.Api; import org.apache.solr.api.ApiBag; import org.apache.solr.api.ApiBag.ReqHandlerToApi; +import org.apache.solr.api.JerseyResource; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.common.SolrErrorWrappingException; import org.apache.solr.common.SolrException; @@ -48,6 +49,9 @@ import org.apache.solr.handler.admin.api.GetAuthorizationConfigAPI; import org.apache.solr.handler.admin.api.ModifyNoAuthPluginSecurityConfigAPI; import org.apache.solr.handler.admin.api.ModifyNoAuthzPluginSecurityConfigAPI; +import org.apache.solr.handler.admin.api.Permissions; +import org.apache.solr.handler.admin.api.Roles; +import org.apache.solr.handler.admin.api.Users; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.security.AuthenticationPlugin; @@ -89,37 +93,41 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw if (SolrRequest.METHOD.GET.equals(httpMethod)) { getConf(rsp, key); } else if (SolrRequest.METHOD.POST.equals(httpMethod)) { - Object plugin = getPlugin(key); - doEdit(req, rsp, path, key, plugin); + doEdit(req, rsp, key); } } - private void doEdit( - SolrQueryRequest req, - SolrQueryResponse rsp, - String path, - final String key, - final Object plugin) + private void doEdit(SolrQueryRequest req, SolrQueryResponse rsp, final String key) throws IOException { - ConfigEditablePlugin configEditablePlugin = null; + if (req.getContentStreams() == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No contentStream"); + } + List ops = ApiBag.readCommands(req.getContentStreams(), rsp.getValues()); + if (ops == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No commands"); + } + editSecurityConfig(req, key, ops); + } + /** + * Applies the given commands to the named security plugin's configuration ("authentication" or + * "authorization"), retrying up to 3 times if persisting the result races with a concurrent edit, + * then persists it. Shared by the legacy command-batch {@code /admin/authentication} and {@code + * /admin/authorization} handling above and by the resource-oriented v2 Jersey APIs (e.g. {@code + * org.apache.solr.handler.admin.api.Users}). + */ + public void editSecurityConfig(SolrQueryRequest req, String key, List ops) + throws IOException { + Object plugin = getPlugin(key); if (plugin == null) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, "No " + key + " plugin configured"); } - if (plugin instanceof ConfigEditablePlugin) { - configEditablePlugin = (ConfigEditablePlugin) plugin; - } else { + if (!(plugin instanceof ConfigEditablePlugin)) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, key + " plugin is not editable"); } + ConfigEditablePlugin configEditablePlugin = (ConfigEditablePlugin) plugin; - if (req.getContentStreams() == null) { - throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No contentStream"); - } - List ops = ApiBag.readCommands(req.getContentStreams(), rsp.getValues()); - if (ops == null) { - throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No commands"); - } for (int count = 1; count <= 3; count++) { SecurityConfig securityConfig = getSecurityConfig(true); Map data = securityConfig.getData(); @@ -360,4 +368,9 @@ public synchronized Map getCommandSchema() { public Boolean registerV2() { return Boolean.TRUE; } + + @Override + public Collection> getJerseyResources() { + return List.of(Users.class, Roles.class, Permissions.class); + } } diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/Permissions.java b/solr/core/src/java/org/apache/solr/handler/admin/api/Permissions.java new file mode 100644 index 000000000000..b394abfdf06c --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/Permissions.java @@ -0,0 +1,228 @@ +/* + * 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.admin.api; + +import static org.apache.solr.security.PermissionNameProvider.Name.SECURITY_EDIT_PERM; +import static org.apache.solr.security.PermissionNameProvider.Name.SECURITY_READ_PERM; + +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.solr.client.api.endpoint.AuthorizationPermissionsApi; +import org.apache.solr.client.api.model.CreatePermissionResponse; +import org.apache.solr.client.api.model.ListPermissionsResponse; +import org.apache.solr.client.api.model.PermissionDefinition; +import org.apache.solr.client.api.model.PermissionDetails; +import org.apache.solr.client.api.model.SolrJerseyResponse; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.util.CommandOperation; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.handler.admin.SecurityConfHandler; +import org.apache.solr.jersey.PermissionName; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; + +/** + * V2 API for managing Rule-Based Authorization permissions. + * + *

A resource-oriented alternative to the {@code set-permission}/{@code update-permission}/ + * {@code delete-permission} commands accepted by {@link ModifyRuleBasedAuthConfigAPI}, via {@link + * SecurityConfHandler#editSecurityConfig}. A permission's {@code index} - its position in the + * evaluated-top-down list - moves from a body field to a path parameter. + */ +public class Permissions extends AdminAPIBase implements AuthorizationPermissionsApi { + private static final String AUTHORIZATION_KEY = "authorization"; + + private final SecurityConfHandler securityConfHandler; + + @Inject + public Permissions( + CoreContainer coreContainer, + SolrQueryRequest solrQueryRequest, + SolrQueryResponse solrQueryResponse) { + super(coreContainer, solrQueryRequest, solrQueryResponse); + this.securityConfHandler = coreContainer.getSecurityConfHandler(); + } + + @Override + @PermissionName(SECURITY_READ_PERM) + public ListPermissionsResponse listPermissions() { + final var response = instantiateJerseyResponse(ListPermissionsResponse.class); + List permissions = new ArrayList<>(); + for (Map raw : fetchPermissions()) { + permissions.add(toPermissionDetails(raw)); + } + response.permissions = permissions; + return response; + } + + @Override + @PermissionName(SECURITY_EDIT_PERM) + public CreatePermissionResponse createPermission(PermissionDefinition requestBody) + throws Exception { + if (requestBody == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Missing required request body"); + } + // Computed before the edit below, rather than by re-reading afterwards: in SolrCloud, a + // getSecurityConfig(false) read immediately following our own write can still observe the + // pre-write cached snapshot, since the ZK watcher that refreshes it fires asynchronously. + int existingCount = fetchPermissions().size(); + + Map dataMap = toDataMap(requestBody, /* includeBefore= */ true); + securityConfHandler.editSecurityConfig( + solrQueryRequest, + AUTHORIZATION_KEY, + List.of(new CommandOperation("set-permission", dataMap))); + + final var response = instantiateJerseyResponse(CreatePermissionResponse.class); + // A create with no "before" is always appended at the end of the (freshly re-numbered) + // list, so it ends up one past the pre-edit count; a create with "before: N" always takes + // over index N directly, since renumbering starts fresh at 1 and preserves relative order. + response.index = requestBody.before != null ? requestBody.before : existingCount + 1; + return response; + } + + @Override + @PermissionName(SECURITY_EDIT_PERM) + public SolrJerseyResponse updatePermission(int index, PermissionDefinition requestBody) + throws Exception { + if (requestBody == null) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Missing required request body"); + } + ensurePermissionExists(index); + + Map dataMap = toDataMap(requestBody, /* includeBefore= */ true); + dataMap.put("index", index); + + final var response = instantiateJerseyResponse(SolrJerseyResponse.class); + securityConfHandler.editSecurityConfig( + solrQueryRequest, + AUTHORIZATION_KEY, + List.of(new CommandOperation("update-permission", dataMap))); + return response; + } + + @Override + @PermissionName(SECURITY_EDIT_PERM) + public SolrJerseyResponse deletePermission(int index) throws Exception { + ensurePermissionExists(index); + + final var response = instantiateJerseyResponse(SolrJerseyResponse.class); + securityConfHandler.editSecurityConfig( + solrQueryRequest, + AUTHORIZATION_KEY, + List.of(new CommandOperation("delete-permission", index))); + return response; + } + + private void ensurePermissionExists(int index) { + boolean found = + fetchPermissions().stream() + .anyMatch(p -> p.get("index") instanceof Number n && n.intValue() == index); + if (!found) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, "No permission exists with index [" + index + "]"); + } + } + + @SuppressWarnings("unchecked") + private List> fetchPermissions() { + Map authorizationConf = + (Map) + securityConfHandler.getSecurityConfig(false).getData().get(AUTHORIZATION_KEY); + if (authorizationConf == null) { + return List.of(); + } + // The "permissions" value is always list-shaped in security.json, but it isn't guaranteed to + // arrive as a java.util.List: Utils.getDeepCopy(..., mutable=false) - used when building + // read-only snapshots of a cached security config - wraps it in + // Collections.unmodifiableCollection(), which only implements Collection, not List. + Object rawPermissions = authorizationConf.get("permissions"); + if (!(rawPermissions instanceof Collection)) { + return List.of(); + } + List> permissions = new ArrayList<>(); + for (Object p : (Collection) rawPermissions) { + permissions.add((Map) p); + } + return permissions; + } + + private static PermissionDetails toPermissionDetails(Map raw) { + PermissionDetails details = new PermissionDetails(); + populateDefinitionFields(details, raw); + Object index = raw.get("index"); + details.index = index instanceof Number ? ((Number) index).intValue() : null; + return details; + } + + @SuppressWarnings("unchecked") + private static void populateDefinitionFields( + PermissionDefinition definition, Map raw) { + definition.name = (String) raw.get("name"); + definition.role = asList(raw.get("role")); + definition.collection = asList(raw.get("collection")); + definition.path = asList(raw.get("path")); + definition.method = asList(raw.get("method")); + Object params = raw.get("params"); + definition.params = params instanceof Map ? (Map) params : null; + } + + @SuppressWarnings("unchecked") + private static List asList(Object value) { + if (value == null) { + return null; + } + if (value instanceof List) { + return (List) value; + } + return List.of(String.valueOf(value)); + } + + /** + * Converts the non-null fields of a {@link PermissionDefinition} request body into the {@code + * Map} shape the legacy {@code set-permission}/{@code update-permission} commands expect. + */ + private static Map toDataMap(PermissionDefinition def, boolean includeBefore) { + Map dataMap = new LinkedHashMap<>(); + if (def.name != null) { + dataMap.put("name", def.name); + } + if (def.role != null) { + dataMap.put("role", def.role); + } + if (def.collection != null) { + dataMap.put("collection", def.collection); + } + if (def.path != null) { + dataMap.put("path", def.path); + } + if (def.method != null) { + dataMap.put("method", def.method); + } + if (def.params != null) { + dataMap.put("params", def.params); + } + if (includeBefore && def.before != null) { + dataMap.put("before", def.before); + } + return dataMap; + } +} diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java b/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java new file mode 100644 index 000000000000..c0159f139b67 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java @@ -0,0 +1,167 @@ +/* + * 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.admin.api; + +import static org.apache.solr.security.PermissionNameProvider.Name.SECURITY_EDIT_PERM; +import static org.apache.solr.security.PermissionNameProvider.Name.SECURITY_READ_PERM; + +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.apache.solr.client.api.endpoint.AuthorizationRolesApi; +import org.apache.solr.client.api.model.GetUserRolesResponse; +import org.apache.solr.client.api.model.SetUserRolesRequestBody; +import org.apache.solr.client.api.model.SolrJerseyResponse; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.util.CommandOperation; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.handler.admin.SecurityConfHandler; +import org.apache.solr.jersey.PermissionName; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; +import org.apache.solr.security.MultiAuthRuleBasedAuthorizationPlugin; + +/** + * V2 API for mapping roles to users under Rule-Based Authorization. + * + *

A resource-oriented alternative to the {@code set-user-role} command accepted by {@link + * ModifyRuleBasedAuthConfigAPI}, via {@link SecurityConfHandler#editSecurityConfig}. {@link + * #deleteUserRoles} replaces that command's {@code null}-value idiom for revoking a user's roles. + * + *

When {@link MultiAuthRuleBasedAuthorizationPlugin} is configured, its {@code edit()} requires + * "set-user-role" commands wrapped as {@code {"": {...}}} to route them to the right + * sub-plugin, and its config stores each scheme's role mappings under {@code schemes[].user-role} + * rather than a top-level "user-role" map. {@link #buildCommand} and {@link #fetchUserRoleMap} + * handle both shapes transparently; the {@code scheme} path parameter is simply ignored for a plain + * (non-multi) {@code RuleBasedAuthorizationPlugin}. Permissions, unlike roles, are shared across + * every scheme (see {@link Permissions}), so no such handling is needed there. + */ +public class Roles extends AdminAPIBase implements AuthorizationRolesApi { + private static final String AUTHORIZATION_KEY = "authorization"; + + private final SecurityConfHandler securityConfHandler; + + @Inject + public Roles( + CoreContainer coreContainer, + SolrQueryRequest solrQueryRequest, + SolrQueryResponse solrQueryResponse) { + super(coreContainer, solrQueryRequest, solrQueryResponse); + this.securityConfHandler = coreContainer.getSecurityConfHandler(); + } + + @Override + @PermissionName(SECURITY_READ_PERM) + public GetUserRolesResponse getUserRoles(String scheme, String username) { + final var response = instantiateJerseyResponse(GetUserRolesResponse.class); + response.roles = normalizeToList(fetchUserRoleMap(scheme).get(username)); + return response; + } + + @Override + @PermissionName(SECURITY_EDIT_PERM) + public SolrJerseyResponse setUserRoles( + String scheme, String username, SetUserRolesRequestBody requestBody) throws Exception { + if (requestBody == null || requestBody.roles == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Missing required field 'roles'"); + } + final var response = instantiateJerseyResponse(SolrJerseyResponse.class); + securityConfHandler.editSecurityConfig( + solrQueryRequest, + AUTHORIZATION_KEY, + List.of(buildCommand(scheme, Map.of(username, requestBody.roles)))); + return response; + } + + @Override + @PermissionName(SECURITY_EDIT_PERM) + public SolrJerseyResponse deleteUserRoles(String scheme, String username) throws Exception { + final var response = instantiateJerseyResponse(SolrJerseyResponse.class); + Map revokeRoles = new HashMap<>(); + revokeRoles.put(username, null); + securityConfHandler.editSecurityConfig( + solrQueryRequest, AUTHORIZATION_KEY, List.of(buildCommand(scheme, revokeRoles))); + return response; + } + + private boolean isMultiAuth() { + return coreContainer.getAuthorizationPlugin() instanceof MultiAuthRuleBasedAuthorizationPlugin; + } + + private CommandOperation buildCommand(String scheme, Object data) { + if (isMultiAuth()) { + return new CommandOperation("set-user-role", Map.of(scheme.toLowerCase(Locale.ROOT), data)); + } + return new CommandOperation("set-user-role", data); + } + + @SuppressWarnings("unchecked") + private Map fetchUserRoleMap(String scheme) { + Map authorizationConf = + (Map) + securityConfHandler.getSecurityConfig(false).getData().get(AUTHORIZATION_KEY); + if (authorizationConf == null) { + return Map.of(); + } + Map pluginConf = + isMultiAuth() ? findScheme(authorizationConf, scheme) : authorizationConf; + if (pluginConf == null) { + return Map.of(); + } + Map userRole = (Map) pluginConf.get("user-role"); + return userRole == null ? Map.of() : userRole; + } + + @SuppressWarnings("unchecked") + private static Map findScheme(Map pluginConf, String scheme) { + Object rawSchemes = pluginConf.get("schemes"); + if (!(rawSchemes instanceof Collection)) { + return null; + } + for (Object s : (Collection) rawSchemes) { + if (s instanceof Map) { + Map schemeMap = (Map) s; + if (scheme.equalsIgnoreCase(String.valueOf(schemeMap.get("scheme")))) { + return schemeMap; + } + } + } + return null; + } + + private static List normalizeToList(Object rolesValue) { + if (rolesValue == null) { + return List.of(); + } + // Not guaranteed to arrive as a java.util.List: Utils.getDeepCopy(..., mutable=false), used + // when building read-only snapshots of a cached security config, wraps collections in + // Collections.unmodifiableCollection(), which only implements Collection, not List. + if (rolesValue instanceof Collection) { + List roles = new ArrayList<>(); + for (Object r : (Collection) rolesValue) { + roles.add(String.valueOf(r)); + } + return roles; + } + return List.of(String.valueOf(rolesValue)); + } +} diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/Users.java b/solr/core/src/java/org/apache/solr/handler/admin/api/Users.java new file mode 100644 index 000000000000..cfc714f87807 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/Users.java @@ -0,0 +1,156 @@ +/* + * 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.admin.api; + +import static org.apache.solr.security.PermissionNameProvider.Name.SECURITY_EDIT_PERM; +import static org.apache.solr.security.PermissionNameProvider.Name.SECURITY_READ_PERM; + +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.apache.solr.client.api.endpoint.AuthenticationUsersApi; +import org.apache.solr.client.api.model.ListUsersResponse; +import org.apache.solr.client.api.model.SetUserRequestBody; +import org.apache.solr.client.api.model.SolrJerseyResponse; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.util.CommandOperation; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.handler.admin.SecurityConfHandler; +import org.apache.solr.jersey.PermissionName; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; +import org.apache.solr.security.MultiAuthPlugin; + +/** + * V2 API for managing Basic Authentication users. + * + *

A resource-oriented alternative to the "set-user"/"delete-user" commands accepted by {@link + * ModifyBasicAuthConfigAPI}. Both act on the same underlying {@code + * org.apache.solr.security.Sha256AuthenticationProvider}, via {@link + * SecurityConfHandler#editSecurityConfig}. + * + *

When {@link MultiAuthPlugin} is configured, its {@code edit()} requires every command's data + * wrapped as {@code {"": {...}}} to route it to the right sub-plugin, and its config stores + * each scheme's users under {@code schemes[].credentials} rather than a top-level "credentials" + * map. {@link #buildCommand} and {@link #fetchCredentials} handle both shapes transparently based + * on which plugin is actually configured; the {@code scheme} path parameter is simply ignored for a + * plain (non-multi) {@code BasicAuthPlugin}. + */ +public class Users extends AdminAPIBase implements AuthenticationUsersApi { + private static final String AUTHENTICATION_KEY = "authentication"; + + private final SecurityConfHandler securityConfHandler; + + @Inject + public Users( + CoreContainer coreContainer, + SolrQueryRequest solrQueryRequest, + SolrQueryResponse solrQueryResponse) { + super(coreContainer, solrQueryRequest, solrQueryResponse); + this.securityConfHandler = coreContainer.getSecurityConfHandler(); + } + + @Override + @PermissionName(SECURITY_READ_PERM) + public ListUsersResponse listUsers(String scheme) { + final var response = instantiateJerseyResponse(ListUsersResponse.class); + response.users = new ArrayList<>(fetchCredentials(scheme).keySet()); + return response; + } + + @Override + @PermissionName(SECURITY_EDIT_PERM) + public SolrJerseyResponse createOrUpdateUser( + String scheme, String username, SetUserRequestBody requestBody) throws Exception { + if (requestBody == null || requestBody.password == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Missing required field 'password'"); + } + final var response = instantiateJerseyResponse(SolrJerseyResponse.class); + securityConfHandler.editSecurityConfig( + solrQueryRequest, + AUTHENTICATION_KEY, + List.of(buildCommand("set-user", scheme, Map.of(username, requestBody.password)))); + return response; + } + + @Override + @PermissionName(SECURITY_EDIT_PERM) + public SolrJerseyResponse deleteUser(String scheme, String username) throws Exception { + Map credentials = fetchCredentials(scheme); + if (!credentials.containsKey(username)) { + throw new SolrException(SolrException.ErrorCode.NOT_FOUND, "No such user [" + username + "]"); + } + if (credentials.size() == 1) { + throw new SolrException(SolrException.ErrorCode.CONFLICT, "Cannot delete the last user"); + } + final var response = instantiateJerseyResponse(SolrJerseyResponse.class); + securityConfHandler.editSecurityConfig( + solrQueryRequest, + AUTHENTICATION_KEY, + List.of(buildCommand("delete-user", scheme, List.of(username)))); + return response; + } + + private boolean isMultiAuth() { + return coreContainer.getAuthenticationPlugin() instanceof MultiAuthPlugin; + } + + private CommandOperation buildCommand(String name, String scheme, Object data) { + if (isMultiAuth()) { + return new CommandOperation(name, Map.of(scheme.toLowerCase(Locale.ROOT), data)); + } + return new CommandOperation(name, data); + } + + @SuppressWarnings("unchecked") + private Map fetchCredentials(String scheme) { + Map authenticationConf = + (Map) + securityConfHandler.getSecurityConfig(false).getData().get(AUTHENTICATION_KEY); + if (authenticationConf == null) { + return Map.of(); + } + Map pluginConf = + isMultiAuth() ? findScheme(authenticationConf, scheme) : authenticationConf; + if (pluginConf == null) { + return Map.of(); + } + Map credentials = (Map) pluginConf.get("credentials"); + return credentials == null ? Map.of() : credentials; + } + + @SuppressWarnings("unchecked") + private static Map findScheme(Map pluginConf, String scheme) { + Object rawSchemes = pluginConf.get("schemes"); + if (!(rawSchemes instanceof Collection)) { + return null; + } + for (Object s : (Collection) rawSchemes) { + if (s instanceof Map) { + Map schemeMap = (Map) s; + if (scheme.equalsIgnoreCase(String.valueOf(schemeMap.get("scheme")))) { + return schemeMap; + } + } + } + return null; + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/MultiAuthUsersAndRolesApiCloudTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/MultiAuthUsersAndRolesApiCloudTest.java new file mode 100644 index 000000000000..7bfc2cb9ba82 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/MultiAuthUsersAndRolesApiCloudTest.java @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.handler.admin.api; + +import static org.apache.solr.security.Sha256AuthenticationProvider.getSaltedHashedValue; + +import java.util.List; +import java.util.Map; +import org.apache.solr.client.api.model.GetUserRolesResponse; +import org.apache.solr.client.api.model.ListUsersResponse; +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.request.AuthenticationApi; +import org.apache.solr.client.solrj.request.AuthorizationApi; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.util.Utils; +import org.apache.solr.security.MultiAuthPlugin; +import org.apache.solr.security.MultiAuthRuleBasedAuthorizationPlugin; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Proves the {@code scheme} path segment on {@link Users}/{@link Roles} actually reaches the right + * sub-plugin under {@link MultiAuthPlugin}/{@link MultiAuthRuleBasedAuthorizationPlugin} - two + * configured schemes ("basic" and "other", both real {@code BasicAuthPlugin}/{@code + * RuleBasedAuthorizationPlugin} instances) must stay fully isolated from each other: writing to one + * scheme's users/roles must not appear under the other. + */ +public class MultiAuthUsersAndRolesApiCloudTest extends SolrCloudTestCase { + + private static final String ADMIN_USER = "solr"; + private static final String ADMIN_PASS = "SolrRocks"; + private static final String SEED_USER = "seed"; + private static final String SEED_PASS = "SeedPass123"; + + private static final String SECURITY_JSON = + Utils.toJSONString( + Map.of( + "authentication", + Map.of( + "class", + MultiAuthPlugin.class.getName(), + "schemes", + List.of( + Map.of( + "scheme", + "basic", + "class", + "solr.BasicAuthPlugin", + "blockUnknown", + true, + "credentials", + Map.of(ADMIN_USER, getSaltedHashedValue(ADMIN_PASS))), + Map.of( + "scheme", + "other", + "class", + "solr.BasicAuthPlugin", + "blockUnknown", + true, + "credentials", + Map.of(SEED_USER, getSaltedHashedValue(SEED_PASS))))), + "authorization", + Map.of( + "class", + MultiAuthRuleBasedAuthorizationPlugin.class.getName(), + "schemes", + List.of( + Map.of( + "scheme", + "basic", + "class", + "solr.RuleBasedAuthorizationPlugin", + "user-role", + Map.of(ADMIN_USER, List.of("admin"))), + Map.of( + "scheme", + "other", + "class", + "solr.RuleBasedAuthorizationPlugin", + "user-role", + Map.of())), + "permissions", + List.of(Map.of("name", "all", "role", "admin"))))); + + @Before + public void setupCluster() throws Exception { + configureCluster(1) + .addConfig("conf", configset("cloud-minimal")) + .withSecurityJson(SECURITY_JSON) + .configure(); + } + + @After + public void tearDownCluster() throws Exception { + cluster.shutdown(); + } + + private static > T asAdmin(T request) { + request.setBasicAuthCredentials(ADMIN_USER, ADMIN_PASS); + return request; + } + + @Test + public void testUsersAreIsolatedPerScheme() throws Exception { + var client = cluster.getSolrClient(); + + ListUsersResponse basicUsers = + asAdmin(new AuthenticationApi.ListUsers("basic")).process(client); + assertEquals(List.of(ADMIN_USER), basicUsers.users); + + ListUsersResponse otherUsers = + asAdmin(new AuthenticationApi.ListUsers("other")).process(client); + assertEquals(List.of(SEED_USER), otherUsers.users); + + // Create a user under the "other" scheme only. + var create = asAdmin(new AuthenticationApi.CreateOrUpdateUser("other", "newuser")); + create.setPassword("NewUserPass123"); + create.process(client); + + otherUsers = asAdmin(new AuthenticationApi.ListUsers("other")).process(client); + assertEquals(List.of("newuser", SEED_USER), sorted(otherUsers.users)); + + // "basic" scheme is untouched. + basicUsers = asAdmin(new AuthenticationApi.ListUsers("basic")).process(client); + assertEquals(List.of(ADMIN_USER), basicUsers.users); + + asAdmin(new AuthenticationApi.DeleteUser("other", "newuser")).process(client); + otherUsers = asAdmin(new AuthenticationApi.ListUsers("other")).process(client); + assertEquals(List.of(SEED_USER), otherUsers.users); + } + + @Test + public void testRolesAreIsolatedPerScheme() throws Exception { + var client = cluster.getSolrClient(); + + var setRoles = asAdmin(new AuthorizationApi.SetUserRoles("other", SEED_USER)); + setRoles.setRoles(List.of("dev")); + setRoles.process(client); + + GetUserRolesResponse otherRoles = + asAdmin(new AuthorizationApi.GetUserRoles("other", SEED_USER)).process(client); + assertEquals(List.of("dev"), otherRoles.roles); + + // Same username looked up under "basic" is unaffected - "seed" isn't even a basic-scheme user. + GetUserRolesResponse basicRoles = + asAdmin(new AuthorizationApi.GetUserRoles("basic", SEED_USER)).process(client); + assertTrue(basicRoles.roles.isEmpty()); + + asAdmin(new AuthorizationApi.DeleteUserRoles("other", SEED_USER)).process(client); + otherRoles = asAdmin(new AuthorizationApi.GetUserRoles("other", SEED_USER)).process(client); + assertTrue(otherRoles.roles.isEmpty()); + } + + private static List sorted(List values) { + return values.stream().sorted().toList(); + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java new file mode 100644 index 000000000000..d7ce01b12755 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java @@ -0,0 +1,127 @@ +/* + * 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.admin.api; + +import java.util.List; +import org.apache.solr.client.api.model.CreatePermissionResponse; +import org.apache.solr.client.api.model.GetUserRolesResponse; +import org.apache.solr.client.api.model.ListPermissionsResponse; +import org.apache.solr.client.solrj.request.AuthorizationApi; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.util.SecurityJson; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * SolrCloud-mode coverage for {@link Permissions}/{@link Roles}, exercising {@code + * SecurityConfHandlerZk#getSecurityConfig(false)} - the cached read path backed by {@code + * ZkStateReader}'s security-node watcher. + * + *

Unlike the initial (fresh) load, once that watcher's callback has fired at least once, it + * rebuilds its cached snapshot via {@code Utils.getDeepCopy(..., mutable=false)}, which wraps + * nested lists (e.g. "permissions") in {@code Collections.unmodifiableCollection(...)} rather than + * {@code Collections.unmodifiableList(...)} - an object that implements {@code Collection} but not + * {@code List}. Standalone mode ({@code SecurityConfHandlerLocal}) always re-reads security.json + * fresh from disk and never exhibits this, so this gap needs cloud coverage specifically. Creating + * a permission below forces a real ZK write, which trips the watcher and populates the cached, + * wrapped snapshot before the following read. + * + *

This plugin is a plain (non-multi) {@code RuleBasedAuthorizationPlugin}, so the {@code scheme} + * path segment is ignored server-side; "basic" is used here purely by convention. See {@link + * MultiAuthUsersAndRolesApiCloudTest} for coverage of the scheme actually being honored under + * {@code MultiAuthPlugin}/{@code MultiAuthRuleBasedAuthorizationPlugin}. + */ +public class SecurityV2ApiCloudTest extends SolrCloudTestCase { + + private static final String SCHEME = "basic"; + + @Before + public void setupCluster() throws Exception { + configureCluster(1) + .addConfig("conf", configset("cloud-minimal")) + .withSecurityJson(SecurityJson.SIMPLE) + .configure(); + } + + @After + public void tearDownCluster() throws Exception { + cluster.shutdown(); + } + + private static > T authed(T request) { + request.setBasicAuthCredentials(SecurityJson.USER, SecurityJson.PASS); + return request; + } + + @Test + public void testPermissionsSurviveCachedZkRead() throws Exception { + var client = cluster.getSolrClient(); + + var create = authed(new AuthorizationApi.CreatePermission()); + create.setName("read"); + create.setRole(List.of("admin")); + CreatePermissionResponse createResponse = create.process(client); + int index = createResponse.index; + + // This first list forces SecurityConfHandlerZk's cached (getFresh=false) read path, now that + // the create above has tripped the ZK security-node watcher at least once. + ListPermissionsResponse afterCreate = + authed(new AuthorizationApi.ListPermissions()).process(client); + assertTrue( + afterCreate.permissions.stream().anyMatch(p -> Integer.valueOf(index).equals(p.index))); + + var update = authed(new AuthorizationApi.UpdatePermission(index)); + update.setRole(List.of("admin", "dev")); + update.process(client); + + authed(new AuthorizationApi.DeletePermission(index)).process(client); + + // security.json updates propagate to this node's ZK watcher asynchronously, so poll briefly + // rather than asserting on the very next read. + boolean deleted = false; + for (int i = 0; i < 20 && !deleted; i++) { + ListPermissionsResponse afterDelete = + authed(new AuthorizationApi.ListPermissions()).process(client); + deleted = + afterDelete.permissions.stream().noneMatch(p -> Integer.valueOf(index).equals(p.index)); + if (!deleted) { + Thread.sleep(100); + } + } + assertTrue("permission " + index + " was not removed", deleted); + } + + @Test + public void testUserRolesSurviveCachedZkRead() throws Exception { + var client = cluster.getSolrClient(); + + // Force at least one ZK write/watch-fire cycle before reading roles back. + var setRoles = authed(new AuthorizationApi.SetUserRoles(SCHEME, "harry")); + setRoles.setRoles(List.of("dev")); + setRoles.process(client); + + GetUserRolesResponse roles = + authed(new AuthorizationApi.GetUserRoles(SCHEME, "harry")).process(client); + assertEquals(List.of("dev"), roles.roles); + + authed(new AuthorizationApi.DeleteUserRoles(SCHEME, "harry")).process(client); + + roles = authed(new AuthorizationApi.GetUserRoles(SCHEME, "harry")).process(client); + assertTrue(roles.roles.isEmpty()); + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiStandaloneTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiStandaloneTest.java new file mode 100644 index 000000000000..7f3c2bd5aba1 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiStandaloneTest.java @@ -0,0 +1,190 @@ +/* + * 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.admin.api; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasItem; + +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.client.api.model.CreatePermissionResponse; +import org.apache.solr.client.api.model.GetUserRolesResponse; +import org.apache.solr.client.api.model.ListPermissionsResponse; +import org.apache.solr.client.api.model.ListUsersResponse; +import org.apache.solr.client.api.model.PermissionDetails; +import org.apache.solr.client.solrj.RemoteSolrException; +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.request.AuthenticationApi; +import org.apache.solr.client.solrj.request.AuthorizationApi; +import org.apache.solr.util.SecurityJson; +import org.apache.solr.util.SolrJettyTestRule; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +/** + * HTTP tests, under Basic Authentication (with {@code blockUnknown: true}, so every request below + * needs credentials), for the resource-oriented v2 APIs at {@code + * /api/cluster/security/authentication/{scheme}/users} and {@code + * /api/cluster/security/authorization/{scheme}/roles} and {@code .../permissions}, via the + * generated {@link AuthenticationApi} and {@link AuthorizationApi} SolrJ client classes. + * + *

The plugin under test here is a plain (non-multi) {@code BasicAuthPlugin}, so the {@code + * scheme} path segment is ignored server-side; "basic" is used here purely by convention. See + * {@link org.apache.solr.security.MultiAuthPluginTest} for coverage of the scheme actually being + * honored under {@code MultiAuthPlugin}. + */ +public class SecurityV2ApiStandaloneTest extends SolrTestCase { + + private static final String SCHEME = "basic"; + + @ClassRule public static final SolrJettyTestRule solrTestRule = new SolrJettyTestRule(); + + @BeforeClass + public static void setupSolr() throws Exception { + Path solrHome = createTempDir(); + Files.writeString( + solrHome.resolve("security.json"), SecurityJson.SIMPLE, StandardCharsets.UTF_8); + solrTestRule.startSolr(solrHome); + } + + private static > T authed(T request) { + request.setBasicAuthCredentials(SecurityJson.USER, SecurityJson.PASS); + return request; + } + + @Test + public void testUsersLifecycle() throws Exception { + ListUsersResponse users = + authed(new AuthenticationApi.ListUsers(SCHEME)).process(solrTestRule.getAdminClient()); + assertThat(users.users, containsInAnyOrder(SecurityJson.USER)); + + var createTom = authed(new AuthenticationApi.CreateOrUpdateUser(SCHEME, "tom")); + createTom.setPassword("TomIsCool"); + createTom.process(solrTestRule.getAdminClient()); + + users = authed(new AuthenticationApi.ListUsers(SCHEME)).process(solrTestRule.getAdminClient()); + assertThat(users.users, containsInAnyOrder(SecurityJson.USER, "tom")); + + // Unauthenticated mutation is rejected - deliberately NOT using authed() here + final RemoteSolrException unauth = + expectThrows( + RemoteSolrException.class, + () -> + new AuthenticationApi.DeleteUser(SCHEME, "tom") + .process(solrTestRule.getAdminClient())); + assertEquals(401, unauth.code()); + + // Deleting an unknown user is a 404 + final RemoteSolrException notFound = + expectThrows( + RemoteSolrException.class, + () -> + authed(new AuthenticationApi.DeleteUser(SCHEME, "does-not-exist")) + .process(solrTestRule.getAdminClient())); + assertEquals(404, notFound.code()); + + authed(new AuthenticationApi.DeleteUser(SCHEME, "tom")).process(solrTestRule.getAdminClient()); + + users = authed(new AuthenticationApi.ListUsers(SCHEME)).process(solrTestRule.getAdminClient()); + assertThat(users.users, containsInAnyOrder(SecurityJson.USER)); + + // Deleting the last remaining user is a conflict, not silently allowed + final RemoteSolrException conflict = + expectThrows( + RemoteSolrException.class, + () -> + authed(new AuthenticationApi.DeleteUser(SCHEME, SecurityJson.USER)) + .process(solrTestRule.getAdminClient())); + assertEquals(409, conflict.code()); + } + + @Test + public void testRolesLifecycle() throws Exception { + GetUserRolesResponse roles = + authed(new AuthorizationApi.GetUserRoles(SCHEME, "harry")) + .process(solrTestRule.getAdminClient()); + assertTrue(roles.roles.isEmpty()); + + var setRoles = authed(new AuthorizationApi.SetUserRoles(SCHEME, "harry")); + setRoles.setRoles(List.of("dev")); + setRoles.process(solrTestRule.getAdminClient()); + + roles = + authed(new AuthorizationApi.GetUserRoles(SCHEME, "harry")) + .process(solrTestRule.getAdminClient()); + assertThat(roles.roles, containsInAnyOrder("dev")); + + authed(new AuthorizationApi.DeleteUserRoles(SCHEME, "harry")) + .process(solrTestRule.getAdminClient()); + + roles = + authed(new AuthorizationApi.GetUserRoles(SCHEME, "harry")) + .process(solrTestRule.getAdminClient()); + assertTrue(roles.roles.isEmpty()); + } + + @Test + public void testPermissionsLifecycle() throws Exception { + ListPermissionsResponse initial = + authed(new AuthorizationApi.ListPermissions()).process(solrTestRule.getAdminClient()); + int initialCount = initial.permissions.size(); + + var create = authed(new AuthorizationApi.CreatePermission()); + create.setName("read"); + create.setRole(List.of("guest")); + CreatePermissionResponse createResponse = create.process(solrTestRule.getAdminClient()); + assertNotNull(createResponse.index); + int newIndex = createResponse.index; + + ListPermissionsResponse afterCreate = + authed(new AuthorizationApi.ListPermissions()).process(solrTestRule.getAdminClient()); + assertEquals(initialCount + 1, afterCreate.permissions.size()); + assertThat(afterCreate.permissions.stream().map(p -> p.index).toList(), hasItem(newIndex)); + + var update = authed(new AuthorizationApi.UpdatePermission(newIndex)); + update.setRole(List.of("guest", "dev")); + update.process(solrTestRule.getAdminClient()); + + ListPermissionsResponse afterUpdate = + authed(new AuthorizationApi.ListPermissions()).process(solrTestRule.getAdminClient()); + PermissionDetails updated = + afterUpdate.permissions.stream() + .filter(p -> Integer.valueOf(newIndex).equals(p.index)) + .findFirst() + .orElseThrow(); + assertThat(updated.role, containsInAnyOrder("guest", "dev")); + + authed(new AuthorizationApi.DeletePermission(newIndex)).process(solrTestRule.getAdminClient()); + + ListPermissionsResponse afterDelete = + authed(new AuthorizationApi.ListPermissions()).process(solrTestRule.getAdminClient()); + assertEquals(initialCount, afterDelete.permissions.size()); + + // Deleting an already-removed index is a 404 + final RemoteSolrException notFound = + expectThrows( + RemoteSolrException.class, + () -> + authed(new AuthorizationApi.DeletePermission(newIndex)) + .process(solrTestRule.getAdminClient())); + assertEquals(404, notFound.code()); + } +} diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc index 9f1a062feb6d..d324e99912c1 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc @@ -311,6 +311,96 @@ Set a property on the `Basic` plugin when using the `MultiAuthPlugin`: } ---- +== Managing Users with the Users API + +In addition to the `set-user`/`delete-user` commands above, Solr provides a resource-oriented v2 API for managing users, addressing each user directly by name in the URL path instead of batching commands into the shared `/cluster/security/authentication` endpoint. + +=== Users API Entry Point + +* v2: `\http://localhost:8983/api/cluster/security/authentication/\{scheme}/users` + +This endpoint is not collection-specific, so users are created for the entire Solr cluster. + +The `scheme` path segment names the authentication scheme these users belong to, as configured under `MultiAuthPlugin`'s `schemes` list (e.g. `basic`) - see <> above. It is ignored when `MultiAuthPlugin` isn't in use - a plain `BasicAuthPlugin` setup has only one set of users, and any value may be supplied; the examples below use `basic` by convention. + +=== List Users + +Lists the configured usernames. The response never includes password hashes. + +[tabs#list-users-request] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks http://localhost:8983/api/cluster/security/authentication/basic/users +---- +==== +====== + +*Output* + +[source,json] +---- +{ + "responseHeader": {"status": 0, "QTime": 1}, + "users": ["solr", "tom"] +} +---- + +=== Create a User or Change a Password + +Creates a new user, or changes an existing user's password if it already exists. Passwords must not be identical to the username. + +[tabs#create-or-update-user] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks -X PUT http://localhost:8983/api/cluster/security/authentication/basic/users/tom \ + -H 'Content-type:application/json' \ + -d '{"password": "TomIsCool"}' +---- +==== +====== + +=== Delete a User + +Removes a user. Returns a 404 if the username does not exist, or a 409 if it is the last remaining user - at least one user must be configured at all times. + +[tabs#delete-a-user-request] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks -X DELETE http://localhost:8983/api/cluster/security/authentication/basic/users/tom +---- +==== +====== + == Using Basic Auth with SolrJ There are two main ways to use SolrJ with Solr servers protected by basic authentication: either the permissions can be set on each individual request, or the underlying http client can be configured to add credentials to all requests that it sends. 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 c243186a9280..be59ef9c0f8f 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 @@ -493,7 +493,10 @@ For a query to a collection called `collection1` on the other hand, the most spe === Authorization API Endpoint -`/admin/authorization`: takes a set of commands to create permissions, map permissions to roles, and map roles to users. +* v1: `\http://localhost:8983/solr/admin/authorization` +* v2: `\http://localhost:8983/api/cluster/security/authorization` + +This endpoint takes a set of commands to create permissions, map permissions to roles, and map roles to users. === Manage Permissions @@ -511,43 +514,128 @@ The following creates a new permission named "collection-mgr" that is allowed to The permission will be placed before the "read" permission. Note also that we have defined `collection` as `null` because requests to the Collections API are never collection-specific. +[tabs#set-permission-collection-mgr] +====== +V1 API:: ++ +==== [source,bash] -curl --user solr:SolrRocks -H 'Content-type:application/json' -d '{ +---- +curl --user solr:SolrRocks http://localhost:8983/solr/admin/authorization -H 'Content-type:application/json' -d '{ "set-permission": {"collection": null, "path":"/admin/collections", "params":{"action":["LIST", "CREATE"]}, "before": 3, "role": "admin"} -}' http://localhost:8983/solr/admin/authorization +}' +---- +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks http://localhost:8983/api/cluster/security/authorization -H 'Content-type:application/json' -d '{ + "set-permission": {"collection": null, + "path":"/admin/collections", + "params":{"action":["LIST", "CREATE"]}, + "before": 3, + "role": "admin"} +}' +---- +==== +====== Apply an update permission on all collections to a role called `dev` and read permissions to a role called `guest`: +[tabs#set-permission-dev-guest] +====== +V1 API:: ++ +==== [source,bash] -curl --user solr:SolrRocks -H 'Content-type:application/json' -d '{ +---- +curl --user solr:SolrRocks http://localhost:8983/solr/admin/authorization -H 'Content-type:application/json' -d '{ "set-permission": {"name": "update", "role":"dev"}, "set-permission": {"name": "read", "role":"guest"} -}' http://localhost:8983/solr/admin/authorization +}' +---- +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks http://localhost:8983/api/cluster/security/authorization -H 'Content-type:application/json' -d '{ + "set-permission": {"name": "update", "role":"dev"}, + "set-permission": {"name": "read", "role":"guest"} +}' +---- +==== +====== === Update or Delete Permissions Permissions can be accessed using their index in the list. -Use the `/admin/authorization` API to see the existing permissions and their indices. +Use the Authorization API to see the existing permissions and their indices. The following example updates the `'role'` attribute of permission at index `3`: +[tabs#update-permission] +====== +V1 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks http://localhost:8983/solr/admin/authorization -H 'Content-type:application/json' -d '{ + "update-permission": {"index": 3, + "role": ["admin", "dev"]} +}' +---- +==== + +V2 API:: ++ +==== [source,bash] -curl --user solr:SolrRocks -H 'Content-type:application/json' -d '{ +---- +curl --user solr:SolrRocks http://localhost:8983/api/cluster/security/authorization -H 'Content-type:application/json' -d '{ "update-permission": {"index": 3, "role": ["admin", "dev"]} -}' http://localhost:8983/solr/admin/authorization +}' +---- +==== +====== The following example deletes permission at index `3`: +[tabs#delete-permission] +====== +V1 API:: ++ +==== [source,bash] -curl --user solr:SolrRocks -H 'Content-type:application/json' -d '{ +---- +curl --user solr:SolrRocks http://localhost:8983/solr/admin/authorization -H 'Content-type:application/json' -d '{ "delete-permission": 3 -}' http://localhost:8983/solr/admin/authorization +}' +---- +==== +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks http://localhost:8983/api/cluster/security/authorization -H 'Content-type:application/json' -d '{ + "delete-permission": 3 +}' +---- +==== +====== === Map Roles to Users @@ -562,8 +650,240 @@ The values supplied to the command are simply a user ID and one or more roles th For example, the following would grant a user "solr" the "admin" and "dev" roles, and remove all roles from the user ID "harry": +[tabs#set-user-role] +====== +V1 API:: ++ +==== [source,bash] -curl -u solr:SolrRocks -H 'Content-type:application/json' -d '{ +---- +curl -u solr:SolrRocks http://localhost:8983/solr/admin/authorization -H 'Content-type:application/json' -d '{ "set-user-role" : {"solr": ["admin","dev"], "harry": null} -}' http://localhost:8983/solr/admin/authorization +}' +---- +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl -u solr:SolrRocks http://localhost:8983/api/cluster/security/authorization -H 'Content-type:application/json' -d '{ + "set-user-role" : {"solr": ["admin","dev"], + "harry": null} +}' +---- +==== +====== + +== Managing Roles and Permissions with Resource-Oriented APIs + +In addition to the `set-user-role`/`set-permission`/`update-permission`/`delete-permission` commands above, Solr provides resource-oriented v2 APIs for managing roles and permissions, addressing each user or permission directly in the URL path instead of batching commands into the shared `/cluster/security/authorization` endpoint. + +=== Roles API + +==== Roles API Entry Point + +* v2: `\http://localhost:8983/api/cluster/security/authorization/\{scheme}/roles` + +The `scheme` path segment names the authentication scheme these role mappings belong to, as configured under `MultiAuthRuleBasedAuthorizationPlugin`'s `schemes` list (e.g. `basic`). It is ignored when that plugin isn't in use - a plain `RuleBasedAuthorizationPlugin` setup has only one set of role mappings, and any value may be supplied; the examples below use `basic` by convention. Unlike roles, permissions are shared across every scheme, so the Permissions API below has no such segment. + +==== Get a User's Roles + +[tabs#get-user-roles] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks http://localhost:8983/api/cluster/security/authorization/basic/roles/solr +---- +==== +====== + +*Output* + +[source,json] +---- +{ + "responseHeader": {"status": 0, "QTime": 1}, + "roles": ["admin", "dev"] +} +---- + +A user with no roles assigned returns an empty `roles` array, rather than a 404 - the roles map is independent of whether the username exists as a Basic Authentication user. + +==== Assign Roles to a User + +Replaces all of a user's roles with the ones supplied. This is the resource-oriented equivalent of the `set-user-role` command above. + +[tabs#set-roles-for-user] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks -X PUT http://localhost:8983/api/cluster/security/authorization/basic/roles/solr \ + -H 'Content-type:application/json' \ + -d '{"roles": ["admin", "dev"]}' +---- +==== +====== + +==== Revoke a User's Roles + +Removes all roles from a user. This replaces the `set-user-role`-with-`null` idiom above with a real `DELETE`. + +[tabs#delete-user-roles] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks -X DELETE http://localhost:8983/api/cluster/security/authorization/basic/roles/harry +---- +==== +====== + +=== Permissions API + +==== Permissions API Entry Point + +* v2: `\http://localhost:8983/api/cluster/security/authorization/permissions` + +A permission's `index` - its position in the evaluated-top-down list - is addressed as a path parameter (`/permissions/\{index}`) rather than a body field. + +==== List Permissions + +[tabs#list-permissions-request] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks http://localhost:8983/api/cluster/security/authorization/permissions +---- +==== +====== + +*Output* + +[source,json] +---- +{ + "responseHeader": {"status": 0, "QTime": 1}, + "permissions": [ + {"index": 1, "name": "security-edit", "role": ["admin"]}, + {"index": 2, "name": "read", "role": ["guest"]} + ] +} +---- + +==== Create a Permission + +Creates a new permission, appending it at the end of the list unless `before` is supplied to place it immediately before an existing index. The server assigns and returns the new permission's `index`. + +[tabs#create-permission] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks -X POST http://localhost:8983/api/cluster/security/authorization/permissions \ + -H 'Content-type:application/json' \ + -d '{"name": "read", "role": ["guest"]}' +---- +==== +====== + +*Output* + +[source,json] +---- +{ + "responseHeader": {"status": 0, "QTime": 2}, + "index": 2 +} +---- + +==== Update a Permission + +Updates the permission at the given index. This is the resource-oriented equivalent of the `update-permission` command above; the index moves from a body field to the URL path. + +[tabs#update-permission-v2] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks -X PUT http://localhost:8983/api/cluster/security/authorization/permissions/2 \ + -H 'Content-type:application/json' \ + -d '{"role": ["guest", "dev"]}' +---- +==== +====== + +==== Delete a Permission + +Deletes the permission at the given index, then re-numbers the remaining permissions. Returns a 404 if no permission exists at that index. + +[tabs#delete-permission-v2] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks -X DELETE http://localhost:8983/api/cluster/security/authorization/permissions/2 +---- +==== +====== diff --git a/solr/webapp/web/js/angular/controllers/security.js b/solr/webapp/web/js/angular/controllers/security.js index 1bbfd3511ab5..f81a6cf83cd9 100644 --- a/solr/webapp/web/js/angular/controllers/security.js +++ b/solr/webapp/web/js/angular/controllers/security.js @@ -15,7 +15,7 @@ limitations under the License. */ -solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, SystemV2, Security, ApiErrorHandler) { +solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, SystemV2, Security, AuthenticationV2, AuthorizationV2, ApiErrorHandler) { $scope.resetMenu("security", Constants.IS_ROOT_PAGE); $scope.params = []; @@ -23,6 +23,12 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/; + // The Users/Roles v2 APIs address the authentication/authorization scheme they operate on via + // this path segment - this panel only ever manages the "basic" scheme (see multiAuthWithBasic + // below), so it's a constant here rather than something the user picks. The server ignores it + // entirely when MultiAuthPlugin/MultiAuthRuleBasedAuthorizationPlugin isn't configured. + var BASIC_SCHEME = "basic"; + function toList(str) { if (Array.isArray(str)) { return str; // already a list @@ -479,7 +485,6 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki }; $scope.updateUserRoles = function() { - var setUserRoles = {}; var roles = []; if ($scope.upsertUser.selectedRoles) { roles = roles.concat($scope.upsertUser.selectedRoles); @@ -492,9 +497,8 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki } var userRoles = Array.from(new Set(roles)); var username = $scope.upsertUser.username; - setUserRoles[username] = userRoles.length > 0 ? userRoles : null; - var cmdJson = $scope.wrapSchemeCmd("set-user-role", setUserRoles); - Security.post({path: "authorization"}, cmdJson, function (data) { + + function onRolesUpdated() { $scope.toggleUserDialog(); whenReflected("authorization", function (data2) { var authz = $scope.findEditableAuthz(data2); @@ -502,7 +506,19 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki var current = asList(authz["user-role"][username]); return current.length === userRoles.length && userRoles.every(r => current.includes(r)); }, $scope.refreshSecurityPanel); - }); + } + + if (userRoles.length > 0) { + AuthorizationV2.setUserRoles(BASIC_SCHEME, username, {roles: userRoles}, function (error, data, response) { + if (error) { ApiErrorHandler.handle(response); return; } + onRolesUpdated(); + }); + } else { + AuthorizationV2.deleteUserRoles(BASIC_SCHEME, username, function (error, data, response) { + if (error) { ApiErrorHandler.handle(response); return; } + onRolesUpdated(); + }); + } }; $scope.doUpsertUser = function() { @@ -564,21 +580,19 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki delete $scope.validationError; if (doSetUser) { - var setUserJson = {}; - setUserJson[username] = $scope.upsertUser.password.trim(); - var cmdJson = $scope.wrapSchemeCmd("set-user", setUserJson); - Security.post({path: "authentication"}, cmdJson, function (data) { - var errorCause = checkError(data); - if (errorCause != null) { - $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause; - $scope.securityAPIErrorDetails = JSON.stringify(data); - return; - } + var password = $scope.upsertUser.password.trim(); + + function onUserSet() { whenReflected("authentication", function (data2) { return hasCredential(data2, username); }, function () { $scope.updateUserRoles(); }); + } + + AuthenticationV2.createOrUpdateUser(BASIC_SCHEME, username, {password: password}, function (error, data, response) { + if (error) { ApiErrorHandler.handle(response); return; } + onUserSet(); }); } else { $scope.updateUserRoles(); @@ -588,18 +602,24 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki $scope.confirmDeleteUser = function() { var username = $scope.upsertUser.username; if (window.confirm("Confirm delete the '"+username+"' user?")) { - // remove all roles for the user and the delete the user - var removeRoles = {}; - removeRoles[username] = null; - var cmdJson = $scope.wrapSchemeCmd("set-user-role", removeRoles); - Security.post({path: "authorization"}, cmdJson, function (data) { - var deleteUserCmd = $scope.wrapSchemeCmd("delete-user", [username]); - Security.post({path: "authentication"}, deleteUserCmd, function (data2) { - $scope.toggleUserDialog(); - whenReflected("authentication", function (data3) { - return !hasCredential(data3, username); - }, $scope.refreshSecurityPanel); + function afterUserDeleted() { + $scope.toggleUserDialog(); + whenReflected("authentication", function (data3) { + return !hasCredential(data3, username); + }, $scope.refreshSecurityPanel); + } + + function afterRolesRemoved() { + AuthenticationV2.deleteUser(BASIC_SCHEME, username, function (error, data, response) { + if (error) { ApiErrorHandler.handle(response); return; } + afterUserDeleted(); }); + } + + // remove all roles for the user, then delete the user + AuthorizationV2.deleteUserRoles(BASIC_SCHEME, username, function (error, data, response) { + if (error) { ApiErrorHandler.handle(response); return; } + afterRolesRemoved(); }); } }; @@ -703,11 +723,19 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim(); if (window.confirm("Confirm delete the '"+permName+"' permission?")) { var index = parseInt($scope.upsertPerm.index); - Security.post({path: "authorization"}, { "delete-permission": index }, function (data) { + + function afterDeleted() { $scope.togglePermDialog(); whenReflected("authorization", function (data2) { return permissionRoles(data2, permName) == null; }, $scope.refreshSecurityPanel); + } + + // Permissions are shared across every scheme (unlike users/roles), so no scheme parameter + // is needed here even under MultiAuthPlugin. + AuthorizationV2.deletePermission(index, function (error, data, response) { + if (error) { ApiErrorHandler.handle(response); return; } + afterDeleted(); }); } }; diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 59c9add06354..29f4a9ad13ad 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -118,6 +118,18 @@ solrAdminServices.factory('Metrics', delete solrApi.ApiClient.instance.defaultHeaders["User-Agent"]; return new solrApi.AliasesApi(); }) +.factory('AuthenticationV2', + function() { + solrApi.ApiClient.instance.basePath = '/api'; + delete solrApi.ApiClient.instance.defaultHeaders["User-Agent"]; + return new solrApi.AuthenticationApi(); + }) +.factory('AuthorizationV2', + function() { + solrApi.ApiClient.instance.basePath = '/api'; + delete solrApi.ApiClient.instance.defaultHeaders["User-Agent"]; + return new solrApi.AuthorizationApi(); + }) .factory('ShardsV2', function() { solrApi.ApiClient.instance.basePath = '/api'; From f06ae1c4ccd573675cd36bfe04a3760d0b348a9b Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 16 Sep 2026 08:48:09 -0400 Subject: [PATCH 2/5] Wire the Add/Edit Role dialog to the new v2 Roles/Permissions APIs Assigning a role to its users and granting it permissions previously went through the legacy set-user-role/set-permission/update-permission command-batch calls. Migrate both to the resource-oriented v2 endpoints: one AuthorizationV2.setUserRoles PUT per selected user (fanned out, since the new API is per-user rather than the old bulk multi-user command), and AuthorizationV2.createPermission/updatePermission for granting the role to each selected permission. Verified live against a running Basic-Auth-enabled Solr instance: creating a role, assigning it to two users, and granting it a predefined permission all hit the new /authorization/basic/roles/{username} and /authorization/permissions/{index} endpoints with zero console errors, and the resulting state matches what the legacy path used to produce. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Kapco1bnqFBMkFHFPF6MFL --- .../web/js/angular/controllers/security.js | 155 +++++++++--------- 1 file changed, 82 insertions(+), 73 deletions(-) diff --git a/solr/webapp/web/js/angular/controllers/security.js b/solr/webapp/web/js/angular/controllers/security.js index f81a6cf83cd9..dd5e34827d12 100644 --- a/solr/webapp/web/js/angular/controllers/security.js +++ b/solr/webapp/web/js/angular/controllers/security.js @@ -1229,90 +1229,99 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki perms = $scope.upsertRole.grantedPerms; } - // go get the latest role mappings ... - Security.get({path: "authorization"}, function (data) { - var authz = $scope.findEditableAuthz(data); - if (!authz) { - $scope.validationError = "User roles not editable via the UI!"; - return; + // Polls checkFn(cb) - which calls back with true/false - until it reports true or we give up. + function pollUntil(checkFn, done) { + var attemptsLeft = 40; + function poll() { + checkFn(function (ok) { + if (--attemptsLeft <= 0 || ok) { + done(); + } else { + $timeout(poll, 250); + } + }); } + poll(); + } - var userRoles = authz["user-role"]; - var setUserRoles = {}; - for (u in usersForRole) { - var user = usersForRole[u]; - var currentRoles = user in userRoles ? asList(userRoles[user]) : []; - // add the new role for this user if needed - if (!currentRoles.includes(name)) { - currentRoles.push(name); - } - setUserRoles[user] = currentRoles; - } + function userHasRole(user, cb) { + AuthorizationV2.getUserRoles(BASIC_SCHEME, user, function (error, data) { + cb(!error && data.roles.includes(name)); + }); + } - var cmdJson = $scope.wrapSchemeCmd("set-user-role", setUserRoles); - Security.post({path: "authorization"}, cmdJson, function (data2) { + function permissionHasRole(permName, cb) { + AuthorizationV2.listPermissions(function (error, data) { + if (error) { cb(false); return; } + var perm = data.permissions.find(p => p.name === permName); + cb(perm != null && asList(perm.role).includes(name)); + }); + } - var errorCause = checkError(data2); - if (errorCause != null) { - $scope.securityAPIError = "set-user-role for role "+name+" failed due to: "+errorCause; - $scope.securityAPIErrorDetails = JSON.stringify(data2); - return; - } + // Assigns `name` to one user, replacing their role list, then waits for it to be reflected. + function assignRoleToUser(user, done) { + AuthorizationV2.getUserRoles(BASIC_SCHEME, user, function (error, data, response) { + if (error) { ApiErrorHandler.handle(response); done(); return; } + var roles = data.roles.includes(name) ? data.roles : data.roles.concat([name]); + AuthorizationV2.setUserRoles(BASIC_SCHEME, user, {roles: roles}, function (error2, data2, response2) { + if (error2) { ApiErrorHandler.handle(response2); done(); return; } + pollUntil(cb => userHasRole(user, cb), done); + }); + }); + } + + // Grants `name` to one permission - updating it if it already exists, creating it (only if + // predefined) otherwise - then waits for it to be reflected. + function grantPermissionToRole(permName, existingPerms, done) { + var existingPerm = existingPerms.find(p => p.name === permName); + + function afterGrant(error, response) { + if (error) { ApiErrorHandler.handle(response); done(); return; } + pollUntil(cb => permissionHasRole(permName, cb), done); + } - function roleReflected(data3) { - var authz3 = $scope.findEditableAuthz(data3); - if (!authz3) return true; - return usersForRole.every(u => asList(authz3["user-role"][u]).includes(name)); + if (existingPerm) { + var roles = asList(existingPerm.role); + if (!roles.includes(name)) { + roles = roles.concat([name]); } + AuthorizationV2.updatePermission(existingPerm.index, {role: roles}, function (error, data, response) { + afterGrant(error, response); + }); + } else if ($scope.predefinedPermissions.includes(permName)) { + AuthorizationV2.createPermission({name: permName, role: [name]}, function (error, data, response) { + afterGrant(error, response); + }); + } else { + done(); // custom permission that doesn't exist yet - nothing to grant + } + } - if (perms.length === 0) { - // close dialog and refresh the tables ... + function runTasksThenRefresh(tasks) { + var remaining = tasks.length; + if (remaining === 0) { + $scope.toggleRoleDialog(); + $scope.refreshSecurityPanel(); + return; + } + tasks.forEach(task => task(function () { + if (--remaining === 0) { $scope.toggleRoleDialog(); - whenReflected("authorization", roleReflected, $scope.refreshSecurityPanel); - return; + $scope.refreshSecurityPanel(); } + })); + } - var currentPerms = data.authorization["permissions"]; - for (i in perms) { - let permName = perms[i]; - var existingPerm = currentPerms.find(p => p.name === permName); - - if (existingPerm) { - var roleList = []; - if (existingPerm.role) { - if (Array.isArray(existingPerm.role)) { - roleList = existingPerm.role; - } else { - roleList.push(existingPerm.role); - } - } - if (!roleList.includes(name)) { - roleList.push(name); - } - existingPerm.role = roleList; - Security.post({path: "authorization"}, { "update-permission": existingPerm }, function (data3) { - whenReflected("authorization", function (data4) { - var have = permissionRoles(data4, permName); - return roleReflected(data4) && have != null && have.includes(name); - }, $scope.refreshSecurityPanel); - }); - } else { - // new perm ... must be a predefined ... - if ($scope.predefinedPermissions.includes(permName)) { - var setPermission = {name: permName, role:[name]}; - Security.post({path: "authorization"}, { "set-permission": setPermission }, function (data3) { - whenReflected("authorization", function (data4) { - var have = permissionRoles(data4, permName); - return roleReflected(data4) && have != null && have.includes(name); - }, $scope.refreshSecurityPanel); - }); - } // else ignore it - } - } - $scope.toggleRoleDialog(); + var userTasks = usersForRole.map(u => cb => assignRoleToUser(u, cb)); + if (perms.length === 0) { + runTasksThenRefresh(userTasks); + } else { + AuthorizationV2.listPermissions(function (error, permsData, response) { + if (error) { ApiErrorHandler.handle(response); return; } + var existingPerms = permsData.permissions; + runTasksThenRefresh(userTasks.concat(perms.map(p => cb => grantPermissionToRole(p, existingPerms, cb)))); }); - }); - + } }; $scope.editRole = function(row) { From d5a36e1a2e76b3c17262388d92a7d0ff7ab48d3f Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 16 Sep 2026 08:54:43 -0400 Subject: [PATCH 3/5] Simplify doUpsertRole: reuse the existing whenReflected poll instead of a new one The previous version introduced a second, bespoke polling primitive (pollUntil/userHasRole/permissionHasRole) run once per user and once per permission, on top of the pre-existing whenReflected helper the rest of this file already uses to wait out ZK-cache propagation after a write. That's needless duplication for a real concern (the need to wait at all predates this change entirely - it's inherent to SecurityConfHandler's cached ZK reads, not something the v2 API introduced). Collapse back to a single whenReflected("authorization", ...) check after all the per-user/per-permission v2 writes have returned, exactly like the legacy command-batch code did - just checking the users/permissions this dialog actually touched. Also fixes a real bug the first version would have had: only wait on permissions actually written (existing or newly-created predefined ones), not ones silently skipped as custom-and-nonexistent, which would otherwise never converge. Verified live: assigning a role to a user while both updating an existing permission and creating a brand-new predefined one now produces exactly one PUT/POST per write plus two whenReflected polls against the legacy blob GET (down from a poll per resource), with correct resulting state. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Kapco1bnqFBMkFHFPF6MFL --- .../web/js/angular/controllers/security.js | 77 ++++++++----------- 1 file changed, 34 insertions(+), 43 deletions(-) diff --git a/solr/webapp/web/js/angular/controllers/security.js b/solr/webapp/web/js/angular/controllers/security.js index dd5e34827d12..02c5fddeb015 100644 --- a/solr/webapp/web/js/angular/controllers/security.js +++ b/solr/webapp/web/js/angular/controllers/security.js @@ -1229,55 +1229,26 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki perms = $scope.upsertRole.grantedPerms; } - // Polls checkFn(cb) - which calls back with true/false - until it reports true or we give up. - function pollUntil(checkFn, done) { - var attemptsLeft = 40; - function poll() { - checkFn(function (ok) { - if (--attemptsLeft <= 0 || ok) { - done(); - } else { - $timeout(poll, 250); - } - }); - } - poll(); - } - - function userHasRole(user, cb) { - AuthorizationV2.getUserRoles(BASIC_SCHEME, user, function (error, data) { - cb(!error && data.roles.includes(name)); - }); - } - - function permissionHasRole(permName, cb) { - AuthorizationV2.listPermissions(function (error, data) { - if (error) { cb(false); return; } - var perm = data.permissions.find(p => p.name === permName); - cb(perm != null && asList(perm.role).includes(name)); - }); - } - - // Assigns `name` to one user, replacing their role list, then waits for it to be reflected. + // Assigns `name` to one user, replacing their role list. function assignRoleToUser(user, done) { AuthorizationV2.getUserRoles(BASIC_SCHEME, user, function (error, data, response) { if (error) { ApiErrorHandler.handle(response); done(); return; } var roles = data.roles.includes(name) ? data.roles : data.roles.concat([name]); AuthorizationV2.setUserRoles(BASIC_SCHEME, user, {roles: roles}, function (error2, data2, response2) { - if (error2) { ApiErrorHandler.handle(response2); done(); return; } - pollUntil(cb => userHasRole(user, cb), done); + if (error2) { ApiErrorHandler.handle(response2); } + done(); }); }); } // Grants `name` to one permission - updating it if it already exists, creating it (only if - // predefined) otherwise - then waits for it to be reflected. + // predefined) otherwise. function grantPermissionToRole(permName, existingPerms, done) { var existingPerm = existingPerms.find(p => p.name === permName); function afterGrant(error, response) { - if (error) { ApiErrorHandler.handle(response); done(); return; } - pollUntil(cb => permissionHasRole(permName, cb), done); + if (error) { ApiErrorHandler.handle(response); } + done(); } if (existingPerm) { @@ -1297,29 +1268,49 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki } } - function runTasksThenRefresh(tasks) { + function runTasks(tasks, done) { var remaining = tasks.length; if (remaining === 0) { - $scope.toggleRoleDialog(); - $scope.refreshSecurityPanel(); + done(); return; } tasks.forEach(task => task(function () { if (--remaining === 0) { - $scope.toggleRoleDialog(); - $scope.refreshSecurityPanel(); + done(); } })); } + // Once every write above has returned, this is the same single whenReflected("authorization", + // ...) poll the legacy command-batch code used - just checking the users/perms this dialog + // actually touched, rather than re-inventing per-resource polling against the new v2 GETs. + function finishUp(attemptedPerms) { + $scope.toggleRoleDialog(); + whenReflected("authorization", function (data) { + var authz = $scope.findEditableAuthz(data); + if (!authz) return true; + var rolesOk = usersForRole.every(u => asList(authz["user-role"][u]).includes(name)); + var permsOk = attemptedPerms.every(p => { + var have = permissionRoles(data, p); + return have != null && have.includes(name); + }); + return rolesOk && permsOk; + }, $scope.refreshSecurityPanel); + } + var userTasks = usersForRole.map(u => cb => assignRoleToUser(u, cb)); if (perms.length === 0) { - runTasksThenRefresh(userTasks); + runTasks(userTasks, () => finishUp([])); } else { AuthorizationV2.listPermissions(function (error, permsData, response) { - if (error) { ApiErrorHandler.handle(response); return; } + if (error) { ApiErrorHandler.handle(response); runTasks(userTasks, () => finishUp([])); return; } var existingPerms = permsData.permissions; - runTasksThenRefresh(userTasks.concat(perms.map(p => cb => grantPermissionToRole(p, existingPerms, cb)))); + // Only wait on permissions we actually attempted to touch - a custom (non-predefined) + // permission that doesn't exist yet is silently skipped by grantPermissionToRole, and + // would otherwise look permanently "unreflected" to the check above. + var attemptedPerms = perms.filter(p => existingPerms.some(ep => ep.name === p) || $scope.predefinedPermissions.includes(p)); + var permTasks = attemptedPerms.map(p => cb => grantPermissionToRole(p, existingPerms, cb)); + runTasks(userTasks.concat(permTasks), () => finishUp(attemptedPerms)); }); } }; From 28338e2a431e57b63392e99ade74cdeeaa620270 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 16 Sep 2026 09:47:28 -0400 Subject: [PATCH 4/5] Add bulk GET /cluster/security/authorization/{scheme}/roles Adds a list-every-user's-roles endpoint alongside the existing per-user getUserRoles/setUserRoles/deleteUserRoles, returning {"userRoles": {user: [role, ...]}}. This is the missing piece for building a UI table of all role assignments without one request per user - the per-user GET has no bulk equivalent today. Same scheme-isolation guarantees as the rest of the Roles API: under MultiAuthRuleBasedAuthorizationPlugin each scheme's mappings stay separate, verified in MultiAuthUsersAndRolesApiCloudTest; under a plain RuleBasedAuthorizationPlugin the scheme segment is ignored. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Kapco1bnqFBMkFHFPF6MFL --- .../api/endpoint/AuthorizationRolesApi.java | 12 +++++++ .../api/model/ListUserRolesResponse.java | 29 +++++++++++++++++ .../apache/solr/handler/admin/api/Roles.java | 12 +++++++ .../MultiAuthUsersAndRolesApiCloudTest.java | 10 ++++++ .../admin/api/SecurityV2ApiCloudTest.java | 6 ++++ .../api/SecurityV2ApiStandaloneTest.java | 14 ++++++++ .../rule-based-authorization-plugin.adoc | 32 +++++++++++++++++++ 7 files changed, 115 insertions(+) create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/ListUserRolesResponse.java diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationRolesApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationRolesApi.java index b1a539aebf97..460578ab4e4e 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationRolesApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/AuthorizationRolesApi.java @@ -25,6 +25,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import org.apache.solr.client.api.model.GetUserRolesResponse; +import org.apache.solr.client.api.model.ListUserRolesResponse; import org.apache.solr.client.api.model.SetUserRolesRequestBody; import org.apache.solr.client.api.model.SolrJerseyResponse; @@ -44,6 +45,17 @@ */ @Path("/cluster/security/authorization/{scheme}/roles") public interface AuthorizationRolesApi { + @GET + @Operation( + summary = "List every user's role assignments.", + tags = {"authorization"}) + ListUserRolesResponse listUserRoles( + @Parameter( + description = "The authentication scheme to list role mappings for.", + required = true) + @PathParam("scheme") + String scheme); + @GET @Path("/{username}") @Operation( diff --git a/solr/api/src/java/org/apache/solr/client/api/model/ListUserRolesResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/ListUserRolesResponse.java new file mode 100644 index 000000000000..df9fa154e8c0 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/ListUserRolesResponse.java @@ -0,0 +1,29 @@ +/* + * 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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Map; + +public class ListUserRolesResponse extends SolrJerseyResponse { + @Schema(description = "Every user's assigned roles, keyed by username.") + @JsonProperty("userRoles") + public Map> userRoles; +} diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java b/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java index c0159f139b67..3fb8fac949cc 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java @@ -23,11 +23,13 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import org.apache.solr.client.api.endpoint.AuthorizationRolesApi; import org.apache.solr.client.api.model.GetUserRolesResponse; +import org.apache.solr.client.api.model.ListUserRolesResponse; import org.apache.solr.client.api.model.SetUserRolesRequestBody; import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.common.SolrException; @@ -68,6 +70,16 @@ public Roles( this.securityConfHandler = coreContainer.getSecurityConfHandler(); } + @Override + @PermissionName(SECURITY_READ_PERM) + public ListUserRolesResponse listUserRoles(String scheme) { + final var response = instantiateJerseyResponse(ListUserRolesResponse.class); + Map> userRoles = new LinkedHashMap<>(); + fetchUserRoleMap(scheme).forEach((user, roles) -> userRoles.put(user, normalizeToList(roles))); + response.userRoles = userRoles; + return response; + } + @Override @PermissionName(SECURITY_READ_PERM) public GetUserRolesResponse getUserRoles(String scheme, String username) { diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/MultiAuthUsersAndRolesApiCloudTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/MultiAuthUsersAndRolesApiCloudTest.java index 7bfc2cb9ba82..8015cac0c950 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/MultiAuthUsersAndRolesApiCloudTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/MultiAuthUsersAndRolesApiCloudTest.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; import org.apache.solr.client.api.model.GetUserRolesResponse; +import org.apache.solr.client.api.model.ListUserRolesResponse; import org.apache.solr.client.api.model.ListUsersResponse; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.request.AuthenticationApi; @@ -161,6 +162,15 @@ public void testRolesAreIsolatedPerScheme() throws Exception { asAdmin(new AuthorizationApi.GetUserRoles("basic", SEED_USER)).process(client); assertTrue(basicRoles.roles.isEmpty()); + // The bulk listing is scheme-isolated the same way: "seed"/"dev" only shows up under "other". + ListUserRolesResponse otherList = + asAdmin(new AuthorizationApi.ListUserRoles("other")).process(client); + assertEquals(List.of("dev"), otherList.userRoles.get(SEED_USER)); + + ListUserRolesResponse basicList = + asAdmin(new AuthorizationApi.ListUserRoles("basic")).process(client); + assertFalse(basicList.userRoles.containsKey(SEED_USER)); + asAdmin(new AuthorizationApi.DeleteUserRoles("other", SEED_USER)).process(client); otherRoles = asAdmin(new AuthorizationApi.GetUserRoles("other", SEED_USER)).process(client); assertTrue(otherRoles.roles.isEmpty()); diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java index d7ce01b12755..709a8512d6d5 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java @@ -20,6 +20,7 @@ import org.apache.solr.client.api.model.CreatePermissionResponse; import org.apache.solr.client.api.model.GetUserRolesResponse; import org.apache.solr.client.api.model.ListPermissionsResponse; +import org.apache.solr.client.api.model.ListUserRolesResponse; import org.apache.solr.client.solrj.request.AuthorizationApi; import org.apache.solr.cloud.SolrCloudTestCase; import org.apache.solr.util.SecurityJson; @@ -119,6 +120,11 @@ public void testUserRolesSurviveCachedZkRead() throws Exception { authed(new AuthorizationApi.GetUserRoles(SCHEME, "harry")).process(client); assertEquals(List.of("dev"), roles.roles); + // The bulk listing reads the same cached-ZK path as the single-user GET above. + ListUserRolesResponse allRoles = + authed(new AuthorizationApi.ListUserRoles(SCHEME)).process(client); + assertEquals(List.of("dev"), allRoles.userRoles.get("harry")); + authed(new AuthorizationApi.DeleteUserRoles(SCHEME, "harry")).process(client); roles = authed(new AuthorizationApi.GetUserRoles(SCHEME, "harry")).process(client); diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiStandaloneTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiStandaloneTest.java index 7f3c2bd5aba1..82b487d47c47 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiStandaloneTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiStandaloneTest.java @@ -27,6 +27,7 @@ import org.apache.solr.client.api.model.CreatePermissionResponse; import org.apache.solr.client.api.model.GetUserRolesResponse; import org.apache.solr.client.api.model.ListPermissionsResponse; +import org.apache.solr.client.api.model.ListUserRolesResponse; import org.apache.solr.client.api.model.ListUsersResponse; import org.apache.solr.client.api.model.PermissionDetails; import org.apache.solr.client.solrj.RemoteSolrException; @@ -123,6 +124,10 @@ public void testRolesLifecycle() throws Exception { .process(solrTestRule.getAdminClient()); assertTrue(roles.roles.isEmpty()); + ListUserRolesResponse initialList = + authed(new AuthorizationApi.ListUserRoles(SCHEME)).process(solrTestRule.getAdminClient()); + assertFalse(initialList.userRoles.containsKey("harry")); + var setRoles = authed(new AuthorizationApi.SetUserRoles(SCHEME, "harry")); setRoles.setRoles(List.of("dev")); setRoles.process(solrTestRule.getAdminClient()); @@ -132,6 +137,11 @@ public void testRolesLifecycle() throws Exception { .process(solrTestRule.getAdminClient()); assertThat(roles.roles, containsInAnyOrder("dev")); + ListUserRolesResponse afterSetList = + authed(new AuthorizationApi.ListUserRoles(SCHEME)).process(solrTestRule.getAdminClient()); + assertThat(afterSetList.userRoles.get("harry"), containsInAnyOrder("dev")); + assertThat(afterSetList.userRoles.get(SecurityJson.USER), containsInAnyOrder("admin")); + authed(new AuthorizationApi.DeleteUserRoles(SCHEME, "harry")) .process(solrTestRule.getAdminClient()); @@ -139,6 +149,10 @@ public void testRolesLifecycle() throws Exception { authed(new AuthorizationApi.GetUserRoles(SCHEME, "harry")) .process(solrTestRule.getAdminClient()); assertTrue(roles.roles.isEmpty()); + + ListUserRolesResponse afterDeleteList = + authed(new AuthorizationApi.ListUserRoles(SCHEME)).process(solrTestRule.getAdminClient()); + assertFalse(afterDeleteList.userRoles.containsKey("harry")); } @Test 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 be59ef9c0f8f..05e9f8e68104 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 @@ -689,6 +689,38 @@ In addition to the `set-user-role`/`set-permission`/`update-permission`/`delete- The `scheme` path segment names the authentication scheme these role mappings belong to, as configured under `MultiAuthRuleBasedAuthorizationPlugin`'s `schemes` list (e.g. `basic`). It is ignored when that plugin isn't in use - a plain `RuleBasedAuthorizationPlugin` setup has only one set of role mappings, and any value may be supplied; the examples below use `basic` by convention. Unlike roles, permissions are shared across every scheme, so the Permissions API below has no such segment. +==== List Every User's Roles + +Lists every user's role assignments at once - useful for building a UI table without one request per user. + +[tabs#list-user-roles] +====== +V1 API:: ++ +==== +There is no V1 equivalent of this action. +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl --user solr:SolrRocks http://localhost:8983/api/cluster/security/authorization/basic/roles +---- +==== +====== + +*Output* + +[source,json] +---- +{ + "responseHeader": {"status": 0, "QTime": 1}, + "userRoles": {"solr": ["admin"], "harry": ["dev"]} +} +---- + ==== Get a User's Roles [tabs#get-user-roles] From 7f8b57d5353f48c7f9f85e827bb7c19d6ad173d9 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 16 Sep 2026 09:55:20 -0400 Subject: [PATCH 5/5] Read fresh in Users/Roles/Permissions, so writes are visible without polling These three classes' internal reads were all calling SecurityConfHandler#getSecurityConfig(false) - the cached ZK snapshot path, refreshed asynchronously by a watcher after any write - purely because that's what the legacy command-batch GET does, not because it was actually necessary. Under SolrCloud, a GET immediately following a PUT/POST/DELETE could observe the pre-write state until that watcher fires. Switch all three to getSecurityConfig(true) (a no-op for standalone's SecurityConfHandlerLocal, which always reads the file fresh regardless). Since editSecurityConfig only returns 200 once the write is durably persisted, a fresh read afterward is now guaranteed to observe it - no client needs to poll-until-reflected to know when a write "landed." Documented the getFresh semantics on SecurityConfHandler#getSecurityConfig itself, since this wasn't written down anywhere before. SecurityV2ApiCloudTest's two tests no longer need the poll loop they had for exactly this reason - confirmed by 5 consecutive clean runs before removing it, and renamed them to describe what they now actually verify. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Kapco1bnqFBMkFHFPF6MFL --- .../handler/admin/SecurityConfHandler.java | 13 ++++- .../solr/handler/admin/api/Permissions.java | 16 ++++-- .../apache/solr/handler/admin/api/Roles.java | 4 +- .../apache/solr/handler/admin/api/Users.java | 4 +- .../admin/api/SecurityV2ApiCloudTest.java | 55 +++++++++---------- 5 files changed, 56 insertions(+), 36 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java index c287d83f19c6..407e8e30277e 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java @@ -209,7 +209,18 @@ public Category getCategory() { return Category.ADMIN; } - /** Gets security.json from source */ + /** + * Gets security.json from source. + * + *

{@code getFresh=true} reads the source directly - ZooKeeper for {@link + * SecurityConfHandlerZk}, the local file for {@link SecurityConfHandlerLocal} (a no-op there; it + * always reads the file fresh). {@code getFresh=false} may return a locally cached snapshot: for + * {@link SecurityConfHandlerZk} this is refreshed by a ZK watcher that fires asynchronously after + * any write, so a {@code getFresh=false} read issued immediately after this handler's own {@link + * #editSecurityConfig} call can still observe the pre-write state. Callers that need to read back + * a value they (or another request) may have just written - e.g. the v2 Jersey APIs in {@code + * org.apache.solr.handler.admin.api} - should pass {@code true}. + */ public abstract SecurityConfig getSecurityConfig(boolean getFresh); /** Persist security.json to the source, optionally with a version */ diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/Permissions.java b/solr/core/src/java/org/apache/solr/handler/admin/api/Permissions.java index b394abfdf06c..582c3ee03c32 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/Permissions.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/Permissions.java @@ -80,9 +80,10 @@ public CreatePermissionResponse createPermission(PermissionDefinition requestBod if (requestBody == null) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Missing required request body"); } - // Computed before the edit below, rather than by re-reading afterwards: in SolrCloud, a - // getSecurityConfig(false) read immediately following our own write can still observe the - // pre-write cached snapshot, since the ZK watcher that refreshes it fires asynchronously. + // Computed before the edit below, rather than by re-reading and matching content afterwards: + // a fresh permissions list can contain more than one entry with identical fields, so a + // straight positional count avoids the ambiguity that would come from trying to find "the one + // we just added" by content. int existingCount = fetchPermissions().size(); Map dataMap = toDataMap(requestBody, /* includeBefore= */ true); @@ -144,16 +145,21 @@ private void ensurePermissionExists(int index) { @SuppressWarnings("unchecked") private List> fetchPermissions() { + // Read fresh (bypassing SecurityConfHandler's cached ZK snapshot) so a GET immediately + // following one of this class's own writes is guaranteed to observe it - see + // SecurityConfHandler#getSecurityConfig's javadoc for why the cache can otherwise lag a write + // briefly. Map authorizationConf = (Map) - securityConfHandler.getSecurityConfig(false).getData().get(AUTHORIZATION_KEY); + securityConfHandler.getSecurityConfig(true).getData().get(AUTHORIZATION_KEY); if (authorizationConf == null) { return List.of(); } // The "permissions" value is always list-shaped in security.json, but it isn't guaranteed to // arrive as a java.util.List: Utils.getDeepCopy(..., mutable=false) - used when building // read-only snapshots of a cached security config - wraps it in - // Collections.unmodifiableCollection(), which only implements Collection, not List. + // Collections.unmodifiableCollection(), which only implements Collection, not List. Kept as a + // defensive fallback even though this method now always reads fresh. Object rawPermissions = authorizationConf.get("permissions"); if (!(rawPermissions instanceof Collection)) { return List.of(); diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java b/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java index 3fb8fac949cc..705309d86cc6 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/Roles.java @@ -128,9 +128,11 @@ private CommandOperation buildCommand(String scheme, Object data) { @SuppressWarnings("unchecked") private Map fetchUserRoleMap(String scheme) { + // Read fresh - see SecurityConfHandler#getSecurityConfig's javadoc - so a GET immediately + // following one of this class's own writes is guaranteed to observe it. Map authorizationConf = (Map) - securityConfHandler.getSecurityConfig(false).getData().get(AUTHORIZATION_KEY); + securityConfHandler.getSecurityConfig(true).getData().get(AUTHORIZATION_KEY); if (authorizationConf == null) { return Map.of(); } diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/Users.java b/solr/core/src/java/org/apache/solr/handler/admin/api/Users.java index cfc714f87807..59e79a50a508 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/Users.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/Users.java @@ -122,9 +122,11 @@ private CommandOperation buildCommand(String name, String scheme, Object data) { @SuppressWarnings("unchecked") private Map fetchCredentials(String scheme) { + // Read fresh - see SecurityConfHandler#getSecurityConfig's javadoc - so a GET immediately + // following one of this class's own writes is guaranteed to observe it. Map authenticationConf = (Map) - securityConfHandler.getSecurityConfig(false).getData().get(AUTHENTICATION_KEY); + securityConfHandler.getSecurityConfig(true).getData().get(AUTHENTICATION_KEY); if (authenticationConf == null) { return Map.of(); } diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java index 709a8512d6d5..041c5f6fd457 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/SecurityV2ApiCloudTest.java @@ -29,18 +29,22 @@ import org.junit.Test; /** - * SolrCloud-mode coverage for {@link Permissions}/{@link Roles}, exercising {@code - * SecurityConfHandlerZk#getSecurityConfig(false)} - the cached read path backed by {@code - * ZkStateReader}'s security-node watcher. + * SolrCloud-mode coverage for {@link Permissions}/{@link Roles}. Both read via {@code + * SecurityConfHandler#getSecurityConfig(true)} (fresh, bypassing {@code SecurityConfHandlerZk}'s + * cached ZK snapshot) specifically so a GET immediately following one of their own writes is + * guaranteed to observe it, without any client-side polling for propagation - see {@code + * SecurityConfHandler#getSecurityConfig}'s javadoc for why a cached ({@code getFresh=false}) read + * can otherwise lag a write briefly. Standalone mode ({@code SecurityConfHandlerLocal}) always + * reads security.json fresh from disk regardless of this flag, so this behavior needs cloud + * coverage specifically to mean anything. * - *

Unlike the initial (fresh) load, once that watcher's callback has fired at least once, it - * rebuilds its cached snapshot via {@code Utils.getDeepCopy(..., mutable=false)}, which wraps + *

This also incidentally guards against a real bug this suite caught during development: the + * cached path's snapshot is rebuilt via {@code Utils.getDeepCopy(..., mutable=false)}, which wraps * nested lists (e.g. "permissions") in {@code Collections.unmodifiableCollection(...)} rather than * {@code Collections.unmodifiableList(...)} - an object that implements {@code Collection} but not - * {@code List}. Standalone mode ({@code SecurityConfHandlerLocal}) always re-reads security.json - * fresh from disk and never exhibits this, so this gap needs cloud coverage specifically. Creating - * a permission below forces a real ZK write, which trips the watcher and populates the cached, - * wrapped snapshot before the following read. + * {@code List}, which a naive {@code (List<...>) ...} cast would throw a {@code ClassCastException} + * on. {@link Permissions}/{@link Roles} guard against that defensively regardless of which read + * path is in use (see their {@code instanceof Collection} checks). * *

This plugin is a plain (non-multi) {@code RuleBasedAuthorizationPlugin}, so the {@code scheme} * path segment is ignored server-side; "basic" is used here purely by convention. See {@link @@ -70,7 +74,7 @@ private static > T authed( } @Test - public void testPermissionsSurviveCachedZkRead() throws Exception { + public void testPermissionsReadFreshAfterWrite() throws Exception { var client = cluster.getSolrClient(); var create = authed(new AuthorizationApi.CreatePermission()); @@ -79,8 +83,8 @@ public void testPermissionsSurviveCachedZkRead() throws Exception { CreatePermissionResponse createResponse = create.process(client); int index = createResponse.index; - // This first list forces SecurityConfHandlerZk's cached (getFresh=false) read path, now that - // the create above has tripped the ZK security-node watcher at least once. + // Reads fresh - see the class javadoc - so this is expected to see the create above + // immediately, with no propagation delay. ListPermissionsResponse afterCreate = authed(new AuthorizationApi.ListPermissions()).process(client); assertTrue( @@ -92,35 +96,30 @@ public void testPermissionsSurviveCachedZkRead() throws Exception { authed(new AuthorizationApi.DeletePermission(index)).process(client); - // security.json updates propagate to this node's ZK watcher asynchronously, so poll briefly - // rather than asserting on the very next read. - boolean deleted = false; - for (int i = 0; i < 20 && !deleted; i++) { - ListPermissionsResponse afterDelete = - authed(new AuthorizationApi.ListPermissions()).process(client); - deleted = - afterDelete.permissions.stream().noneMatch(p -> Integer.valueOf(index).equals(p.index)); - if (!deleted) { - Thread.sleep(100); - } - } - assertTrue("permission " + index + " was not removed", deleted); + // Reads fresh - see the class javadoc - so this is expected to see the delete immediately, + // with no propagation delay or polling required. + ListPermissionsResponse afterDelete = + authed(new AuthorizationApi.ListPermissions()).process(client); + assertTrue( + "permission " + index + " was not removed", + afterDelete.permissions.stream().noneMatch(p -> Integer.valueOf(index).equals(p.index))); } @Test - public void testUserRolesSurviveCachedZkRead() throws Exception { + public void testUserRolesReadFreshAfterWrite() throws Exception { var client = cluster.getSolrClient(); - // Force at least one ZK write/watch-fire cycle before reading roles back. var setRoles = authed(new AuthorizationApi.SetUserRoles(SCHEME, "harry")); setRoles.setRoles(List.of("dev")); setRoles.process(client); + // Reads fresh - see the class javadoc - so this is expected to see the write above + // immediately, with no propagation delay. GetUserRolesResponse roles = authed(new AuthorizationApi.GetUserRoles(SCHEME, "harry")).process(client); assertEquals(List.of("dev"), roles.roles); - // The bulk listing reads the same cached-ZK path as the single-user GET above. + // The bulk listing reads the same fresh path as the single-user GET above. ListUserRolesResponse allRoles = authed(new AuthorizationApi.ListUserRoles(SCHEME)).process(client); assertEquals(List.of("dev"), allRoles.userRoles.get("harry"));