diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt index 3b9da0754d5..f67bfb40af1 100644 --- a/solr/CHANGES.txt +++ b/solr/CHANGES.txt @@ -359,6 +359,8 @@ New Features * SOLR-15208: Add the countDist aggregation to the stats, facet and timeseries Streaming Expressions (Joel Bernstein) +* SOLR-15527: Security screen in Admin UI for managing users, roles, and permissions (Timothy Potter) + Improvements --------------------- * SOLR-15460: Implement LIKE, IS NOT NULL, IS NULL, and support wildcard * in equals string literal for Parallel SQL (Timothy Potter, Houston Putman) diff --git a/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java index 3ecb4fdca75..5a3c7b94831 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java @@ -33,6 +33,7 @@ import com.codahale.metrics.Gauge; import org.apache.lucene.LucenePackage; +import org.apache.solr.common.cloud.UrlScheme; import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrCore; @@ -351,6 +352,8 @@ public SimpleOrderedMap getSecurityInfo(SolrQueryRequest req) } } + info.add("tls", UrlScheme.HTTPS.equals(UrlScheme.INSTANCE.getUrlScheme())); + return info; } diff --git a/solr/core/src/java/org/apache/solr/util/SolrCLI.java b/solr/core/src/java/org/apache/solr/util/SolrCLI.java index 5f3082515bd..63fb737b1d4 100755 --- a/solr/core/src/java/org/apache/solr/util/SolrCLI.java +++ b/solr/core/src/java/org/apache/solr/util/SolrCLI.java @@ -3977,8 +3977,15 @@ private int handleBasicAuth(CommandLine cli) throws Exception { password = credentials.split(":")[1]; } else { Console console = System.console(); - username = console.readLine("Enter username: "); - password = new String(console.readPassword("Enter password: ")); + // keep prompting until they've entered a non-empty username & password + do { + username = console.readLine("Enter username: "); + } while (username == null || username.trim().length() == 0); + username = username.trim(); + + do { + password = new String(console.readPassword("Enter password: ")); + } while (password.length() == 0); } boolean blockUnknown = Boolean.valueOf(cli.getOptionValue("blockUnknown", "true")); diff --git a/solr/solr-ref-guide/src/basic-authentication-plugin.adoc b/solr/solr-ref-guide/src/basic-authentication-plugin.adoc index 5751f67e366..e3ba25eaba6 100644 --- a/solr/solr-ref-guide/src/basic-authentication-plugin.adoc +++ b/solr/solr-ref-guide/src/basic-authentication-plugin.adoc @@ -26,6 +26,8 @@ To control user permissions, you may need to configure an authorization plugin a To use Basic authentication, you must first create a `security.json` file. This file and where to put it is described in detail in the section <>. +If running in cloud mode, you can use the `bin/solr auth` command-line utility to enable security for a new installation, see: `bin/solr auth --help` for more details. + For Basic authentication, `security.json` must have an `authentication` block which defines the class being used for authentication. Usernames and passwords (as a sha256(password+salt) hash) could be added when the file is created, or can be added later with the Authentication API, described below. diff --git a/solr/solr-ref-guide/src/images/security-ui/add-permission.png b/solr/solr-ref-guide/src/images/security-ui/add-permission.png new file mode 100644 index 00000000000..826022e215c Binary files /dev/null and b/solr/solr-ref-guide/src/images/security-ui/add-permission.png differ diff --git a/solr/solr-ref-guide/src/images/security-ui/edit-user-dialog.png b/solr/solr-ref-guide/src/images/security-ui/edit-user-dialog.png new file mode 100644 index 00000000000..58122155f81 Binary files /dev/null and b/solr/solr-ref-guide/src/images/security-ui/edit-user-dialog.png differ diff --git a/solr/solr-ref-guide/src/images/security-ui/filter-users.png b/solr/solr-ref-guide/src/images/security-ui/filter-users.png new file mode 100644 index 00000000000..e2cc0adb924 Binary files /dev/null and b/solr/solr-ref-guide/src/images/security-ui/filter-users.png differ diff --git a/solr/solr-ref-guide/src/images/security-ui/permissions.png b/solr/solr-ref-guide/src/images/security-ui/permissions.png new file mode 100644 index 00000000000..9e9f4471042 Binary files /dev/null and b/solr/solr-ref-guide/src/images/security-ui/permissions.png differ diff --git a/solr/solr-ref-guide/src/images/security-ui/roles.png b/solr/solr-ref-guide/src/images/security-ui/roles.png new file mode 100644 index 00000000000..ff6b66e5e3e Binary files /dev/null and b/solr/solr-ref-guide/src/images/security-ui/roles.png differ diff --git a/solr/solr-ref-guide/src/images/security-ui/security-not-enabled-warn.png b/solr/solr-ref-guide/src/images/security-ui/security-not-enabled-warn.png new file mode 100644 index 00000000000..ef913d5d7ec Binary files /dev/null and b/solr/solr-ref-guide/src/images/security-ui/security-not-enabled-warn.png differ diff --git a/solr/solr-ref-guide/src/images/security-ui/users.png b/solr/solr-ref-guide/src/images/security-ui/users.png new file mode 100644 index 00000000000..398bebb6be4 Binary files /dev/null and b/solr/solr-ref-guide/src/images/security-ui/users.png differ diff --git a/solr/solr-ref-guide/src/images/solr-admin-ui/security.png b/solr/solr-ref-guide/src/images/solr-admin-ui/security.png new file mode 100644 index 00000000000..49e4672ae4b Binary files /dev/null and b/solr/solr-ref-guide/src/images/solr-admin-ui/security.png differ diff --git a/solr/solr-ref-guide/src/securing-solr.adoc b/solr/solr-ref-guide/src/securing-solr.adoc index 2499fc12d90..31fd7d5db3a 100644 --- a/solr/solr-ref-guide/src/securing-solr.adoc +++ b/solr/solr-ref-guide/src/securing-solr.adoc @@ -2,7 +2,8 @@ :page-children: authentication-and-authorization-plugins, \ audit-logging, \ enabling-ssl, \ - zookeeper-access-control + zookeeper-access-control, \ + security-ui // 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 @@ -39,6 +40,8 @@ See the section <> for details. == Authentication and Authorization +Use the <> screen in the Admin UI to manage users, roles, and permissions. + See chapter <> to learn how to work with the `security.json` file. [#securing-solr-auth-plugins] diff --git a/solr/solr-ref-guide/src/security-ui.adoc b/solr/solr-ref-guide/src/security-ui.adoc new file mode 100644 index 00000000000..1f8363b60ae --- /dev/null +++ b/solr/solr-ref-guide/src/security-ui.adoc @@ -0,0 +1,109 @@ += Security UI +:experimental: +// 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. + +The Security screen allows administrators with the `security-edit` permission to manage users, roles, and permissions. +The Security screen works with Solr running in cloud and standalone modes. + +.Security Screen +image::images/solr-admin-ui/security.png[] + +== Getting Started + +The Security screen warns you if security is not enabled for Solr. You are strongly encouraged to enable security for Solr instances exposed on any network other than localhost. + +image::images/security-ui/security-not-enabled-warn.png[image,width=500] + +When first getting started with Solr, use the `bin/solr auth` command-line utility to enable security for your Solr installation (cloud mode only), see <> for usage instructions. +For example, the following command will enable *basic authentication* and prompt you for the username and password for the initial user with administrative access: +[source,bash] +---- + bin/solr auth enable -type basicAuth -prompt true -z localhost:2181 +---- +_Note: The `auth` utility only works with Solr running in cloud mode and thus requires a Zookeeper connection string passed via the `-z` option._ + +After enabling security, you'll need to refresh the Admin UI and login with the credentials you provided to the `auth` utility to see the updated Security panel. +You do not need to restart Solr as the security configuration will be refreshed from Zookeeper automatically. + +The Security screen provides the following features: + +* Security Settings: Details about the configured authentication and authorization plugins. +* Users: Read, create, update, and delete user accounts if using the <> plugin; this panel is disabled for all other authentication plugins. +* Roles: Read, create, and update roles if using the <> plugin; this panel is disabled for all other authorization plugins. +* Permissions: Read, create, update, and delete permissions if using the <> plugin. + +== User Management + +Administrators can read, create, update, and delete user accounts when using the <> plugin. + +image::images/security-ui/users.png[image,width=500] + +.Limited User Management Capabilities +[NOTE] +==== +Solr's user management is intended to be used by administrators to grant access to protected APIs and lacks common user account management facilities, like password expiration and password self-service (change / reset / recovery). +Consequently, if a user account has been compromised, then an administrator needs to change the password or disable that account using the UI or API. +==== + +To edit a user account, click on the row in the table to open the edit user dialog. You can change a user's password and change their role membership. + +image::images/security-ui/edit-user-dialog.png[image,width=400] + +For systems with many user accounts, use the filter controls at the top of the user table to find users based on common properties. + +image::images/security-ui/filter-users.png[image,width=400] + +For other authentication plugins, such as the <> plugin, this panel will be disabled as users are managed by an external system. + +== Role Management + +<> link users to permissions. If using the <> plugin, administrators can read, create, and update roles. Deleting roles is not supported. + +image::images/security-ui/roles.png[image,width=500] + +To edit a role, simply click on the corresponding row in the table. + +If not using the Rule-based Authorization plugin, the Roles panel will be disabled as user role assignment is managed by an external system. + +== Permission Management + +The *Permissions* panel on the Security screen allows administrators to read, create, update, and delete permissions. + +image::images/security-ui/permissions.png[image,width=900] + +For detailed information about how permissions work in Solr, see: <>. + +=== Add Permission + +Click on the btn:[Add Permission] button to open the Add Permission dialog. + +image::images/security-ui/add-permission.png[image,width=600] + +You can _either_ select a *Predefined* permission from the drop-down select list or provide a unique name for a custom permission. +Creating a new *Predefined* permission is simply a matter of mapping the permission to zero or more roles as the other settings, such as path, are immutable for predefined permissions. +If you need fine-grained control over the path, request method, or collection, then create a custom permission. + +If you do not select any roles for a permission, then the permission is assigned the `null` role, which means grants the permission to anonymous users. +However, if *Block anonymous requests* (`blockUnknown=true`) is checked, then anonymous users will not be allowed to make requests, thus permission with the `null` role are effectively inactive. + +To edit a permission, simply click on the corresponding row in the table. When editing a permission, the current index of the permission in the list of permissions is editable. +This allows you to re-order permissions if needed; see <>. + + + + diff --git a/solr/solr-ref-guide/src/solr-admin-ui.adoc b/solr/solr-ref-guide/src/solr-admin-ui.adoc index 446759ec489..6761ec485a4 100644 --- a/solr/solr-ref-guide/src/solr-admin-ui.adoc +++ b/solr/solr-ref-guide/src/solr-admin-ui.adoc @@ -85,6 +85,14 @@ This server resides at https://issues.apache.org/jira/browse/SOLR. These links cannot be modified without editing the `index.html` in the `server/solr/solr-webapp` directory that contains the Admin UI files. +== Security + +Users with the `security-edit` permission can manage users, roles, and permissions using the <> panel in the Admin UI. +Users with the `security-read` permission can view the Security panel but all update actions on the panel are disabled. + +.Security Screen +image::images/solr-admin-ui/security.png[image,width=800] + == Schema Designer The <> screen provides an interactive experience to create a schema using sample data. @@ -97,7 +105,6 @@ image::images/solr-admin-ui/schema-designer.png[image] The Schema Designer is only available on Solr instances running <>. ==== - == Collection-Specific Tools In the left-hand navigation bar, you will see a pull-down menu titled Collection Selector that can be used to access collection specific administration screens. @@ -139,6 +146,7 @@ Here are sections throughout the Guide describing each screen of the Admin UI: [cols="1,1",frame=none,grid=none,stripes=none] |=== | <>: Recent log messages and configuration of log levels. +| <>: Manage users, roles, and permissions. | <>: Access to SolrCloud node data and status. | <>: Interactively create a schema using sample data. | <>: Collection or Core management tools. diff --git a/solr/webapp/web/css/angular/menu.css b/solr/webapp/web/css/angular/menu.css index c0d09ec69e0..a89e7ca3ec6 100644 --- a/solr/webapp/web/css/angular/menu.css +++ b/solr/webapp/web/css/angular/menu.css @@ -253,7 +253,7 @@ limitations under the License. #menu #index.global p a { background-image: url( ../../img/ico/dashboard.png ); } -#menu #login.global p a { background-image: url( ../../img/ico/users.png ); } +#menu #login.global p a { background-image: url( ../../img/ico/logout.png ); } #menu #logging.global p a { background-image: url( ../../img/ico/inbox-document-text.png ); } #menu #logging.global .level a { background-image: url( ../../img/ico/gear.png ); } @@ -272,6 +272,7 @@ limitations under the License. #menu #cloud.global .graph a { background-image: url( ../../img/ico/molecule.png ); } #menu #schema-designer.global p a { background-image: url( ../../img/ico/book-open-text.png ); } +#menu #security.global p a { background-image: url( ../../img/ico/users.png ); } .sub-menu .ping.error a { diff --git a/solr/webapp/web/css/angular/security.css b/solr/webapp/web/css/angular/security.css new file mode 100644 index 00000000000..2f0a8575372 --- /dev/null +++ b/solr/webapp/web/css/angular/security.css @@ -0,0 +1,678 @@ +/* + +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. + +*/ +#securityPanel +{ + position: relative; +} + +#securityPanel .main-col +{ + float: left; + padding: 7px; + min-width: 700px; +} + +#securityPanel #users h2 { background-image: url( ../../img/ico/users.png ); } +#securityPanel #roles h2 { background-image: url( ../../img/ico/key.png ); } +#securityPanel #permissions h2 { background-image: url( ../../img/ico/lock.png ); } +#securityPanel #authn h2 { background-image: url( ../../img/ico/shield.png ); } + +#securityPanel .ref-guide-link +{ + cursor: pointer; + color: #003eff; + text-decoration-line: underline; + text-decoration-color: #003eff; +} + +#securityPanel .warning-msg +{ + display: block; + font-weight: bold; + margin-bottom: 15px; +} + +#securityPanel .external-msg +{ + display: block; + margin-bottom: 20px; + margin-top: 20px; + margin-left: 30px; +} + +#securityPanel .error-msg +{ + display: block; + font-weight: bold; + color: #c00; + margin-bottom: 15px; +} + +#securityPanel .table-hdr { + border-bottom: 1px solid #dddddd; + padding: 3px; + font-weight: bold; + text-align: left; + word-wrap: break-word; +} + +#securityPanel .td-name { + width: 150px; +} + +#securityPanel .td-role { + width: 100px; +} + +#securityPanel .td-roles { + width: 200px; +} + +#securityPanel .td-coll { + width: 120px; +} + +#securityPanel .td-path { + width: 250px; +} + +#securityPanel .td-method { + width: 100px; +} + +#securityPanel .td-params { + width: 200px; +} + +#securityPanel .table-data { + border-bottom: 1px solid #dddddd; + padding: 3px; + text-align: left; + word-wrap: break-word; + max-width: 450px; +} + +#securityPanel tr.odd +{ + background-color: #f8f8f8; +} + +#securityPanel #authn +{ + display: block; +} + +#securityPanel #user-actions +{ + margin-right: 5px; + float: right; +} + +#securityPanel #perm-actions +{ + margin-right: 5px; + float: right; +} + +#securityPanel #authn form .buttons +{ + margin-top: 10px; + float: right; + width: 71%; +} + +#securityPanel #authn form button.submit +{ + margin-right: 20px; +} + +#securityPanel #authn form button.submit span +{ + background-image: url( ../../img/ico/tick.png ); +} + +#securityPanel #authn form button.reset span +{ + background-image: url( ../../img/ico/cross.png ); +} + +#securityPanel #authn #user-dialog +{ + z-index: 100; + background-color: #fff; + border: 1px solid #f0f0f0; + box-shadow: 5px 5px 10px #c0c0c0; + -moz-box-shadow: 5px 5px 10px #c0c0c0; + -webkit-box-shadow: 5px 5px 10px #c0c0c0; + position: absolute; + padding: 10px; + width: 350px; +} + +#securityPanel #authn #role-dialog +{ + z-index: 100; + background-color: #fff; + border: 1px solid #f0f0f0; + box-shadow: 5px 5px 10px #c0c0c0; + -moz-box-shadow: 5px 5px 10px #c0c0c0; + -webkit-box-shadow: 5px 5px 10px #c0c0c0; + position: absolute; + padding: 10px; + width: 350px; +} + +#securityPanel #authn #add-permission-dialog +{ + z-index: 100; + background-color: #fff; + border: 1px solid #f0f0f0; + box-shadow: 5px 5px 10px #c0c0c0; + -moz-box-shadow: 5px 5px 10px #c0c0c0; + -webkit-box-shadow: 5px 5px 10px #c0c0c0; + position: absolute; + top: 190px; + padding: 10px; + width: 530px; +} + +#securityPanel #authn #add-permission-dialog form label +{ + float: left; + padding-top: 3px; + padding-bottom: 3px; + text-align: right; + width: 110px; + margin-right: 6px; +} + +#securityPanel #authn #user-dialog form label +{ + float: left; + padding-top: 3px; + padding-bottom: 3px; + text-align: right; + width: 37%; + margin-right: 6px; +} + +#securityPanel #authn form p { + padding-bottom: 8px; +} + +#securityPanel #add-user span +{ + background-image: url( ../../img/ico/useradd.png ); +} + +#securityPanel #add-permission +{ + margin-left: 10px; +} + +#securityPanel #add-permission span +{ + background-image: url( ../../img/ico/lockplus.png ); +} + +#securityPanel #authn .validate-error +{ + background-image: url( ../../img/ico/cross.png ); +} + +#securityPanel #authn .validate-error span +{ + color: #c00; + font-weight: bold; + margin-left: 18px; +} + +#securityPanel #authn .formMessageHolder { + display: block; + margin-top: 7px; + height: 56px; + margin-bottom: 7px; + margin-left: 10px; +} + +#securityPanel .form-field { + margin-top: 7px; +} + +#securityPanel #authn .input-text { + width: 142px; +} + +#securityPanel #authn .input-check { + margin-left: 0px; + text-align: left; + width: 25px; +} + +#securityPanel #authn #add_perm_path { + width: 280px; +} + +#securityPanel #authn #add_perm_params { + width: 300px; +} + +#securityPanel #authn #add_user_roles { + width: 148px; +} + +#securityPanel #authn #add_perm_roles { + width: 148px; +} + +#securityPanel #authn #predefined { + width: 172px; +} + +#securityPanel #users-content +{ + height: 230px; + margin-bottom: 20px; +} + +#securityPanel #users-table +{ + height: 200px; + overflow: auto; +} + +#securityPanel #roles-content +{ + height: 230px; + margin-bottom: 20px; +} + +#securityPanel #roles-table +{ + height: 200px; + overflow: auto; +} + +#securityPanel #perms-table +{ + max-height: 400px; + overflow: auto; +} + +#securityPanel .help div.help-perm +{ + z-index: 200; + background-color: #FCF0AD; + border: 1px solid #f0f0f0; + box-shadow: 5px 5px 10px #c0c0c0; + -moz-box-shadow: 5px 5px 10px #c0c0c0; + -webkit-box-shadow: 5px 5px 10px #c0c0c0; + position: absolute; + left: 20px; + top: 90px; + padding: 6px; + width: 420px; +} + +#securityPanel .help-anchor +{ + margin-top: 7px; + margin-left: 18px; + margin-bottom: 10px; +} + +#securityPanel .help-anchor a +{ + color: #003eff; + text-decoration-line: underline; + text-decoration-color: #003eff; +} + +#securityPanel .help-ico { + margin-left: 3px; + margin-top: 3px; +} + +#securityPanel .heading { + display: block; + font-weight: bold; + font-size: 12px; + margin-left: 10px; + margin-bottom: 10px; +} + +#securityPanel .users-left +{ + float: left; + width: 45%; + margin-right: 20px; +} + +#securityPanel .roles-right +{ + overflow: auto; +} + +#securityPanel #user-filters +{ + margin-top: 3px; + margin-bottom: 10px; +} + +#securityPanel #user-filter-type +{ + margin-right: 5px; +} + +#securityPanel #user-filter-text +{ + width: 100px; +} + +#securityPanel #perm-filters +{ + margin-top: 3px; + margin-bottom: 10px; +} + +#securityPanel #perm-filter-type +{ + margin-right: 5px; +} + +#securityPanel #perm-filter-text +{ + width: 100px; +} + +#securityPanel #delete-user +{ + margin-left: 15px; + float: right; +} + +#securityPanel #delete-user span +{ + background-image: url( ../../img/ico/cross-button.png ); +} + +#securityPanel #user-heading { + display: block; + padding: 4px; + margin-bottom: 22px; +} + +#securityPanel #role-heading { + display: block; + padding: 4px; + margin-bottom: 22px; +} + +#securityPanel #perm-heading { + display: block; + padding: 4px; + margin-bottom: 22px; +} + +#securityPanel #delete-perm +{ + margin-left: 15px; + float: right; +} + +#securityPanel #delete-perm span +{ + background-image: url( ../../img/ico/cross-button.png ); +} + +#securityPanel #role-filters +{ + margin-top: 3px; + margin-bottom: 10px; +} + +#securityPanel #role-filter-type +{ + margin-right: 5px; +} + +#securityPanel #role-filter-text +{ + width: 100px; +} + +#securityPanel #role-actions +{ + margin-right: 5px; + float: right; +} + +#securityPanel #add-role span +{ + background-image: url( ../../img/ico/keyplus.png ); +} + +#securityPanel #authn-settings { + margin-top: 5px; + margin-bottom: 20px; +} + +#securityPanel #plugins { + display: block; + margin-bottom: 10px; +} + +#securityPanel #authzPlugin { + margin-left: 40px; +} + +#securityPanel #realm-field { + display: inline; + width: 150px; + margin-right: 30px; +} + +#securityPanel #block-field { + display: inline; + width: 220px; + margin-right: 30px; +} + +#securityPanel #forward-field { + display: inline; + width: 220px; +} + +#securityPanel #blockUnknownHelp +{ + z-index: 200; + background-color: #FCF0AD; + border: 1px solid #f0f0f0; + box-shadow: 5px 5px 10px #c0c0c0; + -moz-box-shadow: 5px 5px 10px #c0c0c0; + -webkit-box-shadow: 5px 5px 10px #c0c0c0; + position: absolute; + left: 210px; + top: 100px; + padding: 6px; + width: 380px; +} + +#securityPanel #forwardCredsHelp +{ + z-index: 200; + background-color: #FCF0AD; + border: 1px solid #f0f0f0; + box-shadow: 5px 5px 10px #c0c0c0; + -moz-box-shadow: 5px 5px 10px #c0c0c0; + -webkit-box-shadow: 5px 5px 10px #c0c0c0; + position: absolute; + left: 450px; + top: 100px; + padding: 6px; + width: 380px; +} + +#securityPanel #authn-content +{ + margin-top: 10px; + height: 80px; + overflow: auto; +} + +#securityPanel #authn #role-dialog form label +{ + float: left; + padding-top: 3px; + padding-bottom: 3px; + text-align: right; + width: 37%; + margin-right: 6px; +} + +#securityPanel #add_perm_custom +{ + margin-left: 5px; + display: inline; +} + +#securityPanel #add_perm_name { + width: 125px; +} + +#securityPanel #perm-select { + margin-top: 12px; + margin-bottom: 15px; +} + +#securityPanel .help div.help-index +{ + z-index: 200; + background-color: #FCF0AD; + border: 1px solid #f0f0f0; + box-shadow: 5px 5px 10px #c0c0c0; + -moz-box-shadow: 5px 5px 10px #c0c0c0; + -webkit-box-shadow: 5px 5px 10px #c0c0c0; + position: absolute; + left: 20px; + top: 90px; + padding: 6px; + width: 380px; +} + +#securityPanel #param-rows +{ + display: block; + height: 100px; + max-height: 100px; + overflow: auto; +} + +#securityPanel .row +{ + display: block; + margin-bottom: 5px; + margin-right: 100px; +} + +#securityPanel .row .param-name +{ + display: inline; + float: left; + width: 90px; +} + +#securityPanel .row .param-value +{ + display: inline; + width: 140px; +} + +#securityPanel .row .param-buttons +{ + float: right; + width: 40px; +} + +#securityPanel .row a +{ + background-position: 50% 50%; + display: block; + height: 25px; + width: 49%; +} + +#securityPanel .row a.add +{ + background-image: url( ../../img/ico/plus-button.png ); + float: right; +} + +#securityPanel .row a.rem +{ + background-image: url( ../../img/ico/minus-button.png ); + float: left; +} + +#securityPanel .error-dialog +{ + z-index: 200; + background-color: #f0f0f0; + border: 1px solid #c00; + box-shadow: 5px 5px 10px #c0c0c0; + -moz-box-shadow: 5px 5px 10px #c0c0c0; + -webkit-box-shadow: 5px 5px 10px #c0c0c0; + position: absolute; + left: 350px; + top: 95px; + padding: 20px; + width: 450px; +} + +#securityPanel #error-dialog #error-dialog-buttons { + float: right; +} + +#securityPanel #error-dialog .error-button { + margin-right: 15px; +} + +#securityPanel #error-dialog .error-button span +{ + background-image: url( ../../img/ico/tick.png ); +} + +#securityPanel #error-dialog-note { + color: #c00; + font-weight: bold; + margin-bottom: 15px; +} + +#securityPanel #error-dialog-details { + min-height: 80px; + margin-bottom: 15px; +} + +#securityPanel #authnPlugin { + margin-left: 20px; +} + +#securityPanel .editable { + cursor: pointer; +} \ No newline at end of file diff --git a/solr/webapp/web/img/ico/key.png b/solr/webapp/web/img/ico/key.png new file mode 100644 index 00000000000..82846369f31 Binary files /dev/null and b/solr/webapp/web/img/ico/key.png differ diff --git a/solr/webapp/web/img/ico/keyplus.png b/solr/webapp/web/img/ico/keyplus.png new file mode 100644 index 00000000000..9c3e361d75e Binary files /dev/null and b/solr/webapp/web/img/ico/keyplus.png differ diff --git a/solr/webapp/web/img/ico/lock.png b/solr/webapp/web/img/ico/lock.png new file mode 100644 index 00000000000..571c16d386f Binary files /dev/null and b/solr/webapp/web/img/ico/lock.png differ diff --git a/solr/webapp/web/img/ico/lockplus.png b/solr/webapp/web/img/ico/lockplus.png new file mode 100644 index 00000000000..e3aa6e34ca3 Binary files /dev/null and b/solr/webapp/web/img/ico/lockplus.png differ diff --git a/solr/webapp/web/img/ico/logout.png b/solr/webapp/web/img/ico/logout.png new file mode 100644 index 00000000000..2bc51acc2ae Binary files /dev/null and b/solr/webapp/web/img/ico/logout.png differ diff --git a/solr/webapp/web/img/ico/shield--exclamation.png b/solr/webapp/web/img/ico/shield--exclamation.png new file mode 100644 index 00000000000..37c57b9f9e4 Binary files /dev/null and b/solr/webapp/web/img/ico/shield--exclamation.png differ diff --git a/solr/webapp/web/img/ico/shield.png b/solr/webapp/web/img/ico/shield.png new file mode 100644 index 00000000000..c41e5abe634 Binary files /dev/null and b/solr/webapp/web/img/ico/shield.png differ diff --git a/solr/webapp/web/img/ico/useradd.png b/solr/webapp/web/img/ico/useradd.png new file mode 100644 index 00000000000..9f6c0f5ffcc Binary files /dev/null and b/solr/webapp/web/img/ico/useradd.png differ diff --git a/solr/webapp/web/index.html b/solr/webapp/web/index.html index 05e837fa69c..cdf780c4502 100644 --- a/solr/webapp/web/index.html +++ b/solr/webapp/web/index.html @@ -27,6 +27,7 @@ + @@ -90,6 +91,7 @@ + @@ -160,6 +162,8 @@

Connection recovered...

+
  • Security

  • +
  • Cloud

    • Nodes
    • diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index 47f0f31869e..28ba743380b 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -177,6 +177,10 @@ solrAdminApp.config([ templateUrl: 'partials/schema-designer.html', controller: 'SchemaDesignerController' }). + when('/~security', { + templateUrl: 'partials/security.html', + controller: 'SecurityController' + }). otherwise({ templateUrl: 'partials/unknown.html', controller: 'UnknownController' @@ -436,7 +440,7 @@ solrAdminApp.config([ $location.path('/login'); } } else { - // schema designer prefers to handle errors itselft + // schema designer prefers to handle errors itself var isHandledBySchemaDesigner = rejection.config.url && rejection.config.url.startsWith("/api/schema-designer/"); if (!isHandledBySchemaDesigner) { $rootScope.exceptions[rejection.config.url] = rejection.data.error; diff --git a/solr/webapp/web/js/angular/controllers/security.js b/solr/webapp/web/js/angular/controllers/security.js new file mode 100644 index 00000000000..93a120e253e --- /dev/null +++ b/solr/webapp/web/js/angular/controllers/security.js @@ -0,0 +1,1123 @@ +/* + 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. +*/ + +solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) { + $scope.resetMenu("security", Constants.IS_ROOT_PAGE); + + $scope.params = []; + + var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/; + + function toList(str) { + if (Array.isArray(str)) { + return str; // already a list + } + return str.trim().split(",").map(s => s.trim()).filter(s => s !== ""); + } + + function asList(listOrStr) { + return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []); + } + + function transposeUserRoles(userRoles) { + var roleUsers = {}; + for (var u in userRoles) { + var roleList = asList(userRoles[u]); + for (var i in roleList) { + var role = roleList[i]; + if (!roleUsers[role]) roleUsers[role] = [] + roleUsers[role].push(u); + } + } + + var roles = []; + for (var r in roleUsers) { + roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))}); + } + return roles.sort((a, b) => (a.name > b.name) ? 1 : -1); + } + + function roleMatch(roles, rolesForUser) { + for (r in rolesForUser) { + if (roles.includes(rolesForUser[r])) + return true; + } + return false; + } + + function permRow(perm, i) { + var roles = asList(perm.role); + var paths = asList(perm.path); + + var collectionNames = ""; + var collections = []; + if ("collection" in perm) { + if (perm["collection"] == null) { + collectionNames = "null"; + } else { + collections = asList(perm.collection); + collectionNames = collections.sort().join(", "); + } + } else { + // no collection property on the perm, so the default "*" applies + collectionNames = ""; + collections.push("*"); + } + + var method = asList(perm.method); + + // perms don't always have an index ?!? + var index = "index" in perm ? perm["index"] : ""+i; + + return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections, + "roles": roles, "paths": paths, "method": method, "params": perm.params }; + } + + function checkError(data) { + var cause = null; + if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) { + cause = "?"; + if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) { + cause = data["errorMessages"][0]["errorMessages"][0]; + } + } + return cause; + } + + function truncateTo(str, maxLen, delim) { + // allow for a little on either side of maxLen for better display + var varLen = Math.min(Math.round(maxLen * 0.1), 15); + if (str.length <= maxLen + varLen) { + return str; + } + + var total = str.split(delim).length; + var at = str.indexOf(delim, maxLen - varLen); + str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen); + var trimmed = str.split(delim).length; + var diff = total - trimmed; + str += " ... "+(diff > 1 ? "(+"+diff+" more)" : ""); + return str; + } + + $scope.closeErrorDialog = function () { + delete $scope.securityAPIError; + delete $scope.securityAPIErrorDetails; + }; + + $scope.displayList = function(listOrStr) { + if (!listOrStr) return ""; + var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim(); + return truncateTo(str, 160, ", "); + }; + + $scope.displayParams = function(obj) { + if (!obj) return ""; + if (Array.isArray(obj)) return obj.sort().join(", "); + + var display = ""; + for (const [key, value] of Object.entries(obj)) { + if (display.length > 0) display += "; "; + display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+"")); + } + return truncateTo(display, 160, "; "); + }; + + $scope.displayRoles = function(obj) { + return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj); + }; + + $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read", + "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read", + "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort(); + + $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"}; + + $scope.errorHandler = function (e) { + var error = e.data && e.data.error ? e.data.error : null; + if (error && error.msg) { + $scope.securityAPIError = error.msg; + $scope.securityAPIErrorDetails = e.data.errorDetails; + } else if (e.data && e.data.message) { + $scope.securityAPIError = e.data.message; + $scope.securityAPIErrorDetails = JSON.stringify(e.data); + } + }; + + $scope.showHelp = function (id) { + if ($scope.helpId && ($scope.helpId === id || id === '')) { + delete $scope.helpId; + } else { + $scope.helpId = id; + } + }; + + $scope.refresh = function () { + $scope.hideAll(); + + $scope.blockUnknown = "false"; // default setting + $scope.realmName = "solr"; + $scope.forwardCredentials = "false"; + + $scope.currentUser = sessionStorage.getItem("auth.username"); + + $scope.userFilter = ""; + $scope.userFilterOption = ""; + $scope.userFilterText = ""; + $scope.userFilterOptions = []; + + $scope.permFilter = ""; + $scope.permFilterOption = ""; + $scope.permFilterOptions = []; + $scope.permFilterTypes = ["", "name", "role", "path", "collection"]; + + System.get(function(data) { + // console.log(">> system: "+JSON.stringify(data)); + $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null; + $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null; + $scope.myRoles = data.security ? data.security["roles"] : []; + $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null; + $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null; + $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : ""; + $scope.solrHome = data["solr_home"]; + $scope.refreshSecurityPanel(); + }, function(e) { + if (e.status === 403) { + $scope.isSecurityAdminEnabled = true; + $scope.hasSecurityEditPerm = false; + $scope.hideAll(); + } + }); + }; + + $scope.hideAll = function () { + // add more dialogs here + delete $scope.validationError; + $scope.showUserDialog = false; + $scope.showPermDialog = false; + delete $scope.helpId; + }; + + $scope.getCurrentUserRoles = function() { + if ($scope.manageUserRolesEnabled) { + return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]]; + } else { + return $scope.myRoles; + } + }; + + $scope.hasPermission = function(permissionName) { + var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles); + return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles())); + }; + + $scope.refreshSecurityPanel = function() { + + // determine if the authorization plugin supports CRUD permissions + $scope.managePermissionsEnabled = + ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" || + $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin"); + + // don't allow CRUD on roles if using external + $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin"; + + Security.get({path: "authorization"}, function (data) { + if (!data.authorization) { + $scope.isSecurityAdminEnabled = false; + $scope.hasSecurityEditPerm = false; + return; + } + + if ($scope.manageUserRolesEnabled) { + $scope.userRoles = data.authorization["user-role"]; + $scope.roles = transposeUserRoles($scope.userRoles); + $scope.filteredRoles = $scope.roles; + $scope.roleNames = $scope.roles.map(r => r.name).sort(); + $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames); + if (!$scope.permFilterTypes.includes("user")) { + $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping + } + } else { + $scope.userRoles = {}; + $scope.roles = []; + $scope.filteredRoles = []; + $scope.roleNames = []; + } + + $scope.permissions = data.authorization["permissions"]; + $scope.permissionsTable = []; + for (p in $scope.permissions) { + $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1)); + } + $scope.filteredPerms = $scope.permissionsTable; + + $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit"); + $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read"); + + if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") { + $scope.manageUsersEnabled = true; + + Security.get({path: "authentication"}, function (data) { + if (!data.authentication) { + // TODO: error msg + $scope.manageUsersEnabled = false; + } + + $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false"; + $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false"; + + if ("realm" in data.authentication) { + $scope.realmName = data.authentication["realm"]; + } + + var users = []; + if (data.authentication.credentials) { + for (var u in data.authentication.credentials) { + var roles = $scope.userRoles[u]; + if (!roles) roles = []; + users.push({"username":u, "roles":roles}); + } + } + $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1); + $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100 + }, $scope.errorHandler); + } else { + $scope.users = []; + $scope.filteredUsers = $scope.users; + $scope.manageUsersEnabled = false; + } + }, $scope.errorHandler); + }; + + $scope.validatePassword = function() { + var password = $scope.upsertUser.password.trim(); + var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : ""; + if (password !== password2) { + $scope.validationError = "Passwords do not match!"; + return false; + } + + if (!password.match(strongPasswordRegex)) { + $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()"; + return false; + } + + return true; + }; + + $scope.updateUserRoles = function() { + var setUserRoles = {}; + var roles = []; + if ($scope.upsertUser.selectedRoles) { + roles = roles.concat($scope.upsertUser.selectedRoles); + } + if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") { + var newRole = $scope.upsertUser.newRole.trim(); + if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) { + roles.push(newRole); + } // else, no new role for you! + } + var userRoles = Array.from(new Set(roles)); + setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null; + Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) { + $scope.toggleUserDialog(); + $scope.refreshSecurityPanel(); + }); + }; + + $scope.doUpsertUser = function() { + if (!$scope.upsertUser) { + delete $scope.validationError; + $scope.showUserDialog = false; + return; + } + + if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") { + $scope.validationError = "Username is required!"; + return; + } + + // keep username to a reasonable length? but allow for email addresses + var username = $scope.upsertUser.username.trim(); + if (username.length > 30) { + $scope.validationError = "Username must be 30 characters or less!"; + return; + } + + var doSetUser = false; + if ($scope.userDialogMode === 'add') { + if ($scope.users) { + var existing = $scope.users.find(u => u.username === username); + if (existing) { + $scope.validationError = "User '"+username+"' already exists!"; + return; + } + } + + if (!$scope.upsertUser.password) { + $scope.validationError = "Password is required!"; + return; + } + + if (!$scope.validatePassword()) { + return; + } + doSetUser = true; + } else { + if ($scope.upsertUser.password) { + if ($scope.validatePassword()) { + doSetUser = true; + } else { + return; // update to password is invalid + } + } // else no update to password + } + + if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") { + var newRole = $scope.upsertUser.newRole.trim(); + if (newRole === "null" || newRole === "*" || newRole.length > 30) { + $scope.validationError = "Invalid new role: "+newRole; + return; + } + } + + delete $scope.validationError; + + if (doSetUser) { + var setUserJson = {}; + setUserJson[username] = $scope.upsertUser.password.trim(); + Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) { + + var errorCause = checkError(data); + if (errorCause != null) { + $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause; + $scope.securityAPIErrorDetails = JSON.stringify(data); + return; + } + + $scope.updateUserRoles(); + }); + } else { + $scope.updateUserRoles(); + } + }; + + $scope.confirmDeleteUser = function() { + if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) { + // remove all roles for the user and the delete the user + var removeRoles = {}; + removeRoles[$scope.upsertUser.username] = null; + Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) { + Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) { + $scope.toggleUserDialog(); + $scope.refreshSecurityPanel(); + }); + }); + } + }; + + $scope.showAddUserDialog = function() { + $scope.userDialogMode = "add"; + $scope.userDialogHeader = "Add New User"; + $scope.userDialogAction = "Add User"; + $scope.upsertUser = {}; + $scope.toggleUserDialog(); + }; + + $scope.toggleUserDialog = function() { + if ($scope.showUserDialog) { + delete $scope.upsertUser; + delete $scope.validationError; + $scope.showUserDialog = false; + return; + } + + $scope.hideAll(); + $('#user-dialog').css({left: 132, top: 132}); + $scope.showUserDialog = true; + }; + + $scope.onPredefinedChanged = function() { + if (!$scope.upsertPerm) { + return; + } + + if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") { + delete $scope.selectedPredefinedPermission; + } else { + $scope.upsertPerm.name = ""; + } + + if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) { + $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission]; + } + + $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission); + }; + + $scope.showAddPermDialog = function() { + $scope.permDialogMode = "add"; + $scope.permDialogHeader = "Add New Permission"; + $scope.permDialogAction = "Add Permission"; + $scope.upsertPerm = {}; + $scope.upsertPerm.name = ""; + $scope.upsertPerm.index = ""; + $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"}; + $scope.isPermFieldDisabled = false; + delete $scope.selectedPredefinedPermission; + + $scope.params = [{"name":"", "value":""}]; + + var permissionNames = $scope.permissions.map(p => p.name); + $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p)); + + $scope.togglePermDialog(); + }; + + $scope.togglePermDialog = function() { + if ($scope.showPermDialog) { + delete $scope.upsertPerm; + delete $scope.validationError; + $scope.showPermDialog = false; + $scope.isPermFieldDisabled = false; + delete $scope.selectedPredefinedPermission; + return; + } + + $scope.hideAll(); + + var leftPos = $scope.permDialogMode === "add" ? 500 : 100; + var topPos = $('#permissions').offset().top - 320; + if (topPos < 0) topPos = 0; + $('#add-permission-dialog').css({left: leftPos, top: topPos}); + + $scope.showPermDialog = true; + }; + + $scope.getMethods = function() { + var methods = []; + if ($scope.upsertPerm.method.get === "true") { + methods.push("GET"); + } + if ($scope.upsertPerm.method.put === "true") { + methods.push("PUT"); + } + if ($scope.upsertPerm.method.post === "true") { + methods.push("POST"); + } + if ($scope.upsertPerm.method.delete === "true") { + methods.push("DELETE"); + } + return methods; + }; + + $scope.confirmDeletePerm = function() { + 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) { + $scope.togglePermDialog(); + $scope.refreshSecurityPanel(); + }); + } + }; + + $scope.doUpsertPermission = function() { + if (!$scope.upsertPerm) { + $scope.upsertPerm = {}; + } + + var isAdd = $scope.permDialogMode === "add"; + var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim(); + + if (isAdd) { + if (!name) { + $scope.validationError = "Either select a predefined permission or provide a name for a custom permission"; + return; + } + var permissionNames = $scope.permissions.map(p => p.name); + if (permissionNames.includes(name)) { + $scope.validationError = "Permission '"+name+"' already exists!"; + return; + } + + if (name === "*") { + $scope.validationError = "Invalid permission name!"; + return; + } + } + + var role = null; + if ($scope.manageUserRolesEnabled) { + role = $scope.upsertPerm.selectedRoles; + if (!role || role.length === 0) { + role = null; + } else if (role.includes("*")) { + role = ["*"]; + } + } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") { + var manualRoles = $scope.upsertPerm.manualRoles.trim(); + role = (manualRoles === "null") ? null : toList(manualRoles); + } + + var setPermJson = {"name": name, "role": role }; + + if ($scope.selectedPredefinedPermission) { + $scope.params = [{"name":"","value":""}]; + } else { + // collection + var coll = null; + if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") { + if ($scope.upsertPerm.collection === "*") { + coll = "*"; + } else { + coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : ""; + } + } + setPermJson["collection"] = coll; + + // path + if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) { + $scope.validationError = "Path is required for custom permissions!"; + return; + } + + setPermJson["path"] = toList($scope.upsertPerm.path); + + if ($scope.upsertPerm.method) { + var methods = $scope.getMethods(); + if (methods.length === 0) { + $scope.validationError = "Must specify at least one request method for a custom permission!"; + return; + } + + if (methods.length < 4) { + setPermJson["method"] = methods; + } // else no need to specify, rule applies to all methods + } + + // params + var params = {}; + if ($scope.params && $scope.params.length > 0) { + for (i in $scope.params) { + var p = $scope.params[i]; + var name = p.name.trim(); + if (name !== "" && p.value) { + if (name in params) { + params[name].push(p.value); + } else { + params[name] = [p.value]; + } + } + } + } + setPermJson["params"] = params; + } + + var indexUpdated = false; + if ($scope.upsertPerm.index) { + var indexOrBefore = isAdd ? "before" : "index"; + var indexInt = parseInt($scope.upsertPerm.index); + if (indexInt < 1) indexInt = 1; + if (indexInt >= $scope.permissions.length) indexInt = null; + if (indexInt != null) { + setPermJson[indexOrBefore] = indexInt; + } + indexUpdated = (!isAdd && indexInt !== parseInt($scope.upsertPerm.originalIndex)); + } + + if (indexUpdated) { + // changing position is a delete + re-add in new position + Security.post({path: "authorization"}, { "delete-permission": parseInt($scope.upsertPerm.originalIndex) }, function (remData) { + if (setPermJson.index) { + var before = setPermJson.index; + delete setPermJson.index; + setPermJson["before"] = before; + } + + // add perm back in new position + Security.post({path: "authorization"}, { "set-permission": setPermJson }, function (data) { + var errorCause = checkError(data); + if (errorCause != null) { + $scope.securityAPIError = "set-permission "+name+" failed due to: "+errorCause; + $scope.securityAPIErrorDetails = JSON.stringify(data); + return; + } + $scope.togglePermDialog(); + $scope.refreshSecurityPanel(); + }); + }); + } else { + var action = isAdd ? "set-permission" : "update-permission"; + var postBody = {}; + postBody[action] = setPermJson; + Security.post({path: "authorization"}, postBody, function (data) { + var errorCause = checkError(data); + if (errorCause != null) { + $scope.securityAPIError = action+" "+name+" failed due to: "+errorCause; + $scope.securityAPIErrorDetails = JSON.stringify(data); + return; + } + + $scope.togglePermDialog(); + $scope.refreshSecurityPanel(); + }); + } + }; + + $scope.applyUserFilter = function() { + $scope.userFilterText = ""; + $scope.userFilterOption = ""; + $scope.userFilterOptions = []; + $scope.filteredUsers = $scope.users; // reset the filtered when the filter type changes + + if ($scope.userFilter === "name" || $scope.userFilter === "path") { + // no-op: filter is text input + } else if ($scope.userFilter === "role") { + $scope.userFilterOptions = $scope.roleNames; + } else if ($scope.userFilter === "perm") { + $scope.userFilterOptions = $scope.permissions.map(p => p.name).sort(); + } else { + $scope.userFilter = ""; + } + }; + + $scope.onUserFilterTextChanged = function() { + // don't fire until we have at least 2 chars ... + if ($scope.userFilterText && $scope.userFilterText.trim().length >= 2) { + $scope.userFilterOption = $scope.userFilterText.toLowerCase(); + $scope.onUserFilterOptionChanged(); + } else { + $scope.filteredUsers = $scope.users; + } + }; + + function pathMatch(paths, filter) { + for (p in paths) { + if (paths[p].includes(filter)) { + return true; + } + } + return false; + } + + $scope.onUserFilterOptionChanged = function() { + var filter = $scope.userFilterOption ? $scope.userFilterOption.trim() : ""; + if (filter.length === 0) { + $scope.filteredUsers = $scope.users; + return; + } + + if ($scope.userFilter === "name") { + $scope.filteredUsers = $scope.users.filter(u => u.username.toLowerCase().includes(filter)); + } else if ($scope.userFilter === "role") { + $scope.filteredUsers = $scope.users.filter(u => u.roles.includes(filter)); + } else if ($scope.userFilter === "path") { + var rolesForPath = Array.from(new Set($scope.permissionsTable.filter(p => p.roles && pathMatch(p.paths, filter)).flatMap(p => p.roles))); + var usersForPath = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPath.includes(r.name)).flatMap(r => r.users))); + $scope.filteredUsers = $scope.users.filter(u => usersForPath.includes(u.username)); + } else if ($scope.userFilter === "perm") { + var rolesForPerm = Array.from(new Set($scope.permissionsTable.filter(p => p.name === filter).flatMap(p => p.roles))); + var usersForPerm = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPerm.includes(r.name)).flatMap(r => r.users))); + $scope.filteredUsers = $scope.users.filter(u => usersForPerm.includes(u.username)); + } else { + // reset + $scope.userFilter = ""; + $scope.userFilterOption = ""; + $scope.userFilterText = ""; + $scope.filteredUsers = $scope.users; + } + }; + + $scope.applyPermFilter = function() { + $scope.permFilterText = ""; + $scope.permFilterOption = ""; + $scope.permFilterOptions = []; + $scope.filteredPerms = $scope.permissionsTable; + + if ($scope.permFilter === "name" || $scope.permFilter === "path") { + // no-op: filter is text input + } else if ($scope.permFilter === "role") { + var roles = $scope.manageUserRolesEnabled ? $scope.roleNames : Array.from(new Set($scope.permissionsTable.flatMap(p => p.roles))).sort(); + $scope.permFilterOptions = ["*", "null"].concat(roles); + } else if ($scope.permFilter === "user") { + $scope.permFilterOptions = Array.from(new Set($scope.roles.flatMap(r => r.users))).sort(); + } else if ($scope.permFilter === "collection") { + $scope.permFilterOptions = Array.from(new Set($scope.permissionsTable.flatMap(p => p.collections))).sort(); + $scope.permFilterOptions.push("null"); + } else { + // no perm filtering + $scope.permFilter = ""; + } + }; + + $scope.onPermFilterTextChanged = function() { + // don't fire until we have at least 2 chars ... + if ($scope.permFilterText && $scope.permFilterText.trim().length >= 2) { + $scope.permFilterOption = $scope.permFilterText.trim().toLowerCase(); + $scope.onPermFilterOptionChanged(); + } else { + $scope.filteredPerms = $scope.permissionsTable; + } + }; + + $scope.onPermFilterOptionChanged = function() { + var filterCriteria = $scope.permFilterOption ? $scope.permFilterOption.trim() : ""; + if (filterCriteria.length === 0) { + $scope.filteredPerms = $scope.permissionsTable; + return; + } + + if ($scope.permFilter === "name") { + $scope.filteredPerms = $scope.permissionsTable.filter(p => p.name.toLowerCase().includes(filterCriteria)); + } else if ($scope.permFilter === "role") { + if (filterCriteria === "null") { + $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length === 0); + } else { + $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.includes(filterCriteria)); + } + } else if ($scope.permFilter === "path") { + $scope.filteredPerms = $scope.permissionsTable.filter(p => pathMatch(p.paths, filterCriteria)); + } else if ($scope.permFilter === "user") { + // get the user's roles and then find all the permissions mapped to each role + var rolesForUser = $scope.roles.filter(r => r.users.includes(filterCriteria)).map(r => r.name); + $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length > 0 && roleMatch(p.roles, rolesForUser)); + } else if ($scope.permFilter === "collection") { + function collectionMatch(collNames, colls, filter) { + return (filter === "null") ?collNames === "null" : colls.includes(filter); + } + $scope.filteredPerms = $scope.permissionsTable.filter(p => collectionMatch(p.collectionNames, p.collections, filterCriteria)); + } else { + // reset + $scope.permFilter = ""; + $scope.permFilterOption = ""; + $scope.permFilterText = ""; + $scope.filteredPerms = $scope.permissionsTable; + } + }; + + $scope.editUser = function(row) { + if (!row || !$scope.hasSecurityEditPerm) { + return; + } + + var userId = row.username; + $scope.userDialogMode = "edit"; + $scope.userDialogHeader = "Edit User: "+userId; + $scope.userDialogAction = "Update"; + var userRoles = userId in $scope.userRoles ? $scope.userRoles[userId] : []; + if (!Array.isArray(userRoles)) { + userRoles = [userRoles]; + } + + $scope.upsertUser = { username: userId, selectedRoles: userRoles }; + $scope.toggleUserDialog(); + }; + + function buildMethods(m) { + return {"get":""+m.includes("GET"), "post":""+m.includes("POST"), "put":""+m.includes("PUT"), "delete":""+m.includes("DELETE")}; + } + + $scope.editPerm = function(row) { + if (!$scope.managePermissionsEnabled || !$scope.hasSecurityEditPerm || !row) { + return; + } + + var name = row.name; + $scope.permDialogMode = "edit"; + $scope.permDialogHeader = "Edit Permission: "+name; + $scope.permDialogAction = "Update"; + + var perm = $scope.permissionsTable.find(p => p.name === name); + var isPredefined = $scope.predefinedPermissions.includes(name); + if (isPredefined) { + $scope.selectedPredefinedPermission = name; + $scope.upsertPerm = { }; + $scope.filteredPredefinedPermissions = []; + $scope.filteredPredefinedPermissions.push(name); + if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) { + $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission]; + } + $scope.isPermFieldDisabled = true; + } else { + $scope.upsertPerm = { name: name, collection: perm.collectionNames, path: perm.paths }; + $scope.params = []; + if (perm.params) { + for (const [key, value] of Object.entries(perm.params)) { + if (Array.isArray(value)) { + for (i in value) { + $scope.params.push({"name":key, "value":value[i]}); + } + } else { + $scope.params.push({"name":key, "value":value}); + } + } + } + if ($scope.params.length === 0) { + $scope.params = [{"name":"","value":""}]; + } + + $scope.upsertPerm["method"] = perm.method.length === 0 ? {"get":"true", "post":"true", "put":"true", "delete":"true"} : buildMethods(perm.method); + $scope.isPermFieldDisabled = false; + delete $scope.selectedPredefinedPermission; + } + + $scope.upsertPerm.index = perm["index"]; + $scope.upsertPerm.originalIndex = perm["index"]; + + // roles depending on authz plugin support + if ($scope.manageUserRolesEnabled) { + $scope.upsertPerm["selectedRoles"] = asList(perm.roles); + } else { + $scope.upsertPerm["manualRoles"] = asList(perm.roles).sort().join(", "); + } + + $scope.togglePermDialog(); + }; + + $scope.applyRoleFilter = function() { + $scope.roleFilterText = ""; + $scope.roleFilterOption = ""; + $scope.roleFilterOptions = []; + $scope.filteredRoles = $scope.roles; // reset the filtered when the filter type changes + + if ($scope.roleFilter === "name" || $scope.roleFilter === "path") { + // no-op: filter is text input + } else if ($scope.roleFilter === "user") { + $scope.roleFilterOptions = Array.from(new Set($scope.roles.flatMap(r => r.users))).sort(); + } else if ($scope.roleFilter === "perm") { + $scope.roleFilterOptions = $scope.permissions.map(p => p.name).sort(); + } else { + $scope.roleFilter = ""; + } + }; + + $scope.onRoleFilterTextChanged = function() { + // don't fire until we have at least 2 chars ... + if ($scope.roleFilterText && $scope.roleFilterText.trim().length >= 2) { + $scope.roleFilterOption = $scope.roleFilterText.toLowerCase(); + $scope.onRoleFilterOptionChanged(); + } else { + $scope.filteredRoles = $scope.roles; + } + }; + + $scope.onRoleFilterOptionChanged = function() { + var filter = $scope.roleFilterOption ? $scope.roleFilterOption.trim() : ""; + if (filter.length === 0) { + $scope.filteredRoles = $scope.roles; + return; + } + + if ($scope.roleFilter === "name") { + $scope.filteredRoles = $scope.roles.filter(r => r.name.toLowerCase().includes(filter)); + } else if ($scope.roleFilter === "user") { + $scope.filteredRoles = $scope.roles.filter(r => r.users.includes(filter)); + } else if ($scope.roleFilter === "path") { + var rolesForPath = Array.from(new Set($scope.permissionsTable.filter(p => p.roles && pathMatch(p.paths, filter)).flatMap(p => p.roles))); + $scope.filteredRoles = $scope.roles.filter(r => rolesForPath.includes(r.name)); + } else if ($scope.roleFilter === "perm") { + var rolesForPerm = Array.from(new Set($scope.permissionsTable.filter(p => p.name === filter).flatMap(p => p.roles))); + $scope.filteredRoles = $scope.roles.filter(r => rolesForPerm.includes(r.name)); + } else { + // reset + $scope.roleFilter = ""; + $scope.roleFilterOption = ""; + $scope.roleFilterText = ""; + $scope.filteredRoles = $scope.roles; + } + }; + + $scope.showAddRoleDialog = function() { + $scope.roleDialogMode = "add"; + $scope.roleDialogHeader = "Add New Role"; + $scope.roleDialogAction = "Add Role"; + $scope.upsertRole = {}; + $scope.userNames = $scope.users.map(u => u.username); + $scope.grantPermissionNames = Array.from(new Set($scope.predefinedPermissions.concat($scope.permissions.map(p => p.name)))).sort(); + $scope.toggleRoleDialog(); + }; + + $scope.toggleRoleDialog = function() { + if ($scope.showRoleDialog) { + delete $scope.upsertRole; + delete $scope.validationError; + delete $scope.userNames; + $scope.showRoleDialog = false; + return; + } + $scope.hideAll(); + $('#role-dialog').css({left: 680, top: 139}); + $scope.showRoleDialog = true; + }; + + $scope.doUpsertRole = function() { + if (!$scope.upsertRole) { + delete $scope.validationError; + $scope.showRoleDialog = false; + return; + } + + if (!$scope.upsertRole.name || $scope.upsertRole.name.trim() === "") { + $scope.validationError = "Role name is required!"; + return; + } + + // keep role name to a reasonable length? but allow for email addresses + var name = $scope.upsertRole.name.trim(); + if (name.length > 30) { + $scope.validationError = "Role name must be 30 characters or less!"; + return; + } + + if (name === "null" || name === "*") { + $scope.validationError = "Role name '"+name+"' is invalid!"; + return; + } + + if ($scope.roleDialogMode === "add") { + if ($scope.roleNames.includes(name)) { + $scope.validationError = "Role '"+name+"' already exists!"; + return; + } + } + + var usersForRole = []; + if ($scope.upsertRole.selectedUsers && $scope.upsertRole.selectedUsers.length > 0) { + usersForRole = usersForRole.concat($scope.upsertRole.selectedUsers); + } + usersForRole = Array.from(new Set(usersForRole)); + if (usersForRole.length === 0) { + $scope.validationError = "Must assign new role '"+name+"' to at least one user."; + return; + } + + var perms = []; + if ($scope.upsertRole.grantedPerms && Array.isArray($scope.upsertRole.grantedPerms) && $scope.upsertRole.grantedPerms.length > 0) { + perms = $scope.upsertRole.grantedPerms; + } + + // go get the latest role mappings ... + Security.get({path: "authorization"}, function (data) { + var userRoles = data.authorization["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; + } + + Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data2) { + + var errorCause = checkError(data2); + if (errorCause != null) { + $scope.securityAPIError = "set-user-role for "+username+" failed due to: "+errorCause; + $scope.securityAPIErrorDetails = JSON.stringify(data2); + return; + } + + if (perms.length === 0) { + // close dialog and refresh the tables ... + $scope.toggleRoleDialog(); + $scope.refreshSecurityPanel(); + return; + } + + var currentPerms = data.authorization["permissions"]; + for (i in perms) { + var 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) { + $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) { + $scope.refreshSecurityPanel(); + }); + } // else ignore it + } + } + $scope.toggleRoleDialog(); + }); + }); + + }; + + $scope.editRole = function(row) { + if (!row || !$scope.hasSecurityEditPerm) { + return; + } + + var roleName = row.name; + $scope.roleDialogMode = "edit"; + $scope.roleDialogHeader = "Edit Role: "+roleName; + $scope.roleDialogAction = "Update"; + var role = $scope.roles.find(r => r.name === roleName); + var perms = $scope.permissionsTable.filter(p => p.roles.includes(roleName)).map(p => p.name); + $scope.upsertRole = { name: roleName, selectedUsers: role.users, grantedPerms: perms }; + $scope.userNames = $scope.users.map(u => u.username); + $scope.grantPermissionNames = Array.from(new Set($scope.predefinedPermissions.concat($scope.permissions.map(p => p.name)))).sort(); + $scope.toggleRoleDialog(); + }; + + $scope.onBlockUnknownChange = function() { + Security.post({path: "authentication"}, { "set-property": { "blockUnknown": $scope.blockUnknown === "true" } }, function (data) { + $scope.refreshSecurityPanel(); + }); + }; + + $scope.onForwardCredsChange = function() { + Security.post({path: "authentication"}, { "set-property": { "forwardCredentials": $scope.forwardCredentials === "true" } }, function (data) { + $scope.refreshSecurityPanel(); + }); + }; + + $scope.removeParam= function(index) { + if ($scope.params.length === 1) { + $scope.params = [{"name":"","value":""}]; + } else { + $scope.params.splice(index, 1); + } + }; + + $scope.addParam = function(index) { + $scope.params.splice(index+1, 0, {"name":"","value":""}); + }; + + $scope.refresh(); +}) diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 4d2c3c3138f..6da98c27189 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -270,6 +270,12 @@ solrAdminServices.factory('System', upload: {method: "POST", transformRequest: angular.identity, headers: {'Content-Type': undefined}, timeout: 90000} }) }]) +.factory('Security', + ['$resource', function($resource) { + return $resource('/api/cluster/security/:path', {wt: 'json', path: '@path', _:Date.now()}, { + get: {method: "GET"}, post: {method: "POST", timeout: 90000} + }) +}]) .factory('AuthenticationService', ['base64', function (base64) { var service = {}; diff --git a/solr/webapp/web/partials/security.html b/solr/webapp/web/partials/security.html new file mode 100644 index 00000000000..b3b7b8af1bc --- /dev/null +++ b/solr/webapp/web/partials/security.html @@ -0,0 +1,285 @@ + +
      +
      +

       Current user is not authenticated! Security panel is disabled.

      +
      + +
      +

       You do not have permission to view the security panel.

      +
      + +
      +

       WARNING: Security is not enabled for this server!

      +
      +

      Use the bin/solr auth command-line tool to enable security and then reload this panel. For more information, see: Using security.json with Solr

      +


      Example usage of bin/solr auth to enable basic authentication:

      +
      +
      +
      +        bin/solr auth enable -type basicAuth -prompt true -z {{zkHost}}
      +
      +      
      +
      +
      +

      Create a security.json config file in your Solr home directory and then restart Solr (on all nodes). For more information, see: Using security.json with Solr

      +
      +
      + +
      + +
      +
      +

      Security Settings

      +
      +
      TLS enabled? Authentication Plugin: {{authenticationPlugin}}Authorization Plugin: {{authorizationPlugin}}
      +
      + + + + +
      +
      +

      If checked, un-authenticated requests to any Solr endpoint are blocked. If un-checked, then any endpoint that is not protected with a permission will be accessible by anonymous users. Only disable this check if you want to allow un-authenticated access to specific endpoints that are configured with role: null. For more information, see: +

      +
      +
      +
      + +
      +
      +

      If checked, Solr forwards user credentials when making distributed requests to other nodes in the cluster. If un-checked (the default), Solr will use the internal PKI authentication mechanism for distributed requests. For more information, see: +

      +
      +
      +
      +
      +
      + +
      +

       {{securityAPIError}}

      +
      +
      + +
      +
      + +
      +
      {{userDialogHeader}}
      +
      +

      +

      +

      +

      +

      +
      +

      {{validationError}}

      +
      +

      + + +

      +
      +
      + +
      +
      {{roleDialogHeader}}
      +
      +

      +

      + +

      +
      +

      {{validationError}}

      +
      +

      + + +

      +
      +
      + +
      +
      {{permDialogHeader}}
      +
      +
      +
      +
      +

      For requests where multiple permissions match, Solr applies the first permission that matches based on a complex ordering logic. In general, more specific permissions should be listed earlier in the configuration. The permission index (1-based) governs its position in the configuration. To re-order a permission, change the index to desired position. +

      +
      +
      +
      +
      or Custom: +
      +
      +

      Permissions allow you to grant access to protected resources to one or more roles. Solr provides a list of predefined permissions to cover common use cases, such as collection administration. Otherwise, you can define a custom permission for fine-grained control over the API path(s), collection(s), request method(s) and params. +

      +
      +
      +
      +

      +

      +

      +
      + + + + + +
      GET
      POST
      PUT
      DELETE
      +
      +
      +
      +
      +  =  +
      + + +
      +
      +
      +
      +
      +

      {{validationError}}

      +
      +

      + + +

      +
      +
      +
      +
      + +
      +
      +

      Users

      +

       Users are managed by an external provider.

      +
      +
      + Filter users by:  + + +
      + +
      +
      + +
      + + + + + + + + + + + +
      UsernameRoles
      {{u.username}}{{displayList(u.roles)}}
      +
      +
      +
      + +
      +

      Roles

      +

       Roles are managed by an external provider.

      +
      +
      + Filter roles by:  + + +
      + +
      +
      + +
      + + + + + + + + + + + +
      RoleUsers
      {{r.name}}{{displayList(r.users)}}
      +
      +
      +
      +
      + +
      +

      Permissions

      +
      +
      + Filter permissions by:  + + +
      + +
      +
      + +
      + + + + + + + + + + + + + + + + + + + +
      NameRolesCollectionPathMethodParams
      {{p.name}}{{displayRoles(p.roles)}}{{p.collectionNames}}{{displayList(p.paths)}}{{displayList(p.method)}}{{displayParams(p.params)}}
      +
      +
      +
      +
      +