This is an automated email from the ASF dual-hosted git repository. thelabdude pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/main by this push: new 2458360 SOLR-15527: Security admin screen for managing users, roles, and permissions (#209) 2458360 is described below commit 245836005303df7bf3ff7f987595a6a3be23baf1 Author: Timothy Potter <thelabd...@gmail.com> AuthorDate: Fri Jul 30 10:28:36 2021 -0600 SOLR-15527: Security admin screen for managing users, roles, and permissions (#209) --- solr/CHANGES.txt | 2 + .../solr/handler/admin/SystemInfoHandler.java | 3 + .../src/java/org/apache/solr/util/SolrCLI.java | 11 +- .../src/basic-authentication-plugin.adoc | 2 + .../src/images/security-ui/add-permission.png | Bin 0 -> 112235 bytes .../src/images/security-ui/edit-user-dialog.png | Bin 0 -> 74147 bytes .../src/images/security-ui/filter-users.png | Bin 0 -> 24808 bytes .../src/images/security-ui/permissions.png | Bin 0 -> 117684 bytes .../src/images/security-ui/roles.png | Bin 0 -> 41003 bytes .../security-ui/security-not-enabled-warn.png | Bin 0 -> 112740 bytes .../src/images/security-ui/users.png | Bin 0 -> 39378 bytes .../src/images/solr-admin-ui/security.png | Bin 0 -> 294588 bytes solr/solr-ref-guide/src/securing-solr.adoc | 5 +- solr/solr-ref-guide/src/security-ui.adoc | 109 ++ solr/solr-ref-guide/src/solr-admin-ui.adoc | 10 +- solr/webapp/web/css/angular/menu.css | 3 +- solr/webapp/web/css/angular/security.css | 678 ++++++++++++ solr/webapp/web/img/ico/key.png | Bin 0 -> 689 bytes solr/webapp/web/img/ico/keyplus.png | Bin 0 -> 812 bytes solr/webapp/web/img/ico/lock.png | Bin 0 -> 1535 bytes solr/webapp/web/img/ico/lockplus.png | Bin 0 -> 1640 bytes solr/webapp/web/img/ico/logout.png | Bin 0 -> 660 bytes solr/webapp/web/img/ico/shield--exclamation.png | Bin 0 -> 813 bytes solr/webapp/web/img/ico/shield.png | Bin 0 -> 782 bytes solr/webapp/web/img/ico/useradd.png | Bin 0 -> 785 bytes solr/webapp/web/index.html | 4 + solr/webapp/web/js/angular/app.js | 6 +- solr/webapp/web/js/angular/controllers/security.js | 1123 ++++++++++++++++++++ solr/webapp/web/js/angular/services.js | 6 + solr/webapp/web/partials/security.html | 285 +++++ 30 files changed, 2241 insertions(+), 6 deletions(-) diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt index 4c0f888..b58f1f4 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 3ecb4fd..5a3c7b9 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 java.util.Set; 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 class SystemInfoHandler extends RequestHandlerBase } } + 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 5f30825..63fb737 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 @@ public class SolrCLI implements CLIO { 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 5751f67..e3ba25e 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 <<authentication-and-authorization-plugins.adoc#configuring-security-json,Configuring security.json>>. +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 0000000..826022e 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 0000000..5812215 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 0000000..e2cc0ad 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 0000000..9e9f447 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 0000000..ff6b66e 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 0000000..ef913d5 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 0000000..398bebb 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 0000000..49e4672 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 2499fc1..31fd7d5 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 <<enabling-ssl.adoc#,Enabling TLS (SSL)>> for details. == Authentication and Authorization +Use the <<security-ui.adoc#,Security>> screen in the Admin UI to manage users, roles, and permissions. + See chapter <<authentication-and-authorization-plugins.adoc#,Configuring Authentication and Authorization>> 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 0000000..1f8363b --- /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 <<solr-control-script-reference.adoc#authentication,bin/solr auth>> 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 <<basic-authentication-plugin.adoc#,Basic Authentication>> plugin; this panel is disabled for all other authentication plugins. +* Roles: Read, create, and update roles if using the <<rule-based-authorization-plugin.adoc#,Rule-based Authorization>> plugin; this panel is disabled for all other authorization plugins. +* Permissions: Read, create, update, and delete permissions if using the <<rule-based-authorization-plugin.adoc#,Rule-based Authorization>> plugin. + +== User Management + +Administrators can read, create, update, and delete user accounts when using the <<basic-authentication-plugin.adoc#,Basic Authentication>> 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 <<jwt-authentication-plugin.adoc#,JWT Authentication>> plugin, this panel will be disabled as users are managed by an external system. + +== Role Management + +<<rule-based-authorization-plugin.adoc#roles,Roles>> link users to permissions. If using the <<rule-based-authorization-plugin.adoc#,Rule-based Authorization>> 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: <<rule-based-authorization-plugin.adoc#permissions,Rule-based Authorization Permissions>>. + +=== 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 <<rule-based-authorization-plugin.adoc#permission-ordering-and-resolution,Permission Ordering>>. + + + + diff --git a/solr/solr-ref-guide/src/solr-admin-ui.adoc b/solr/solr-ref-guide/src/solr-admin-ui.adoc index 446759e..6761ec4 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 <<security-ui.adoc#,Security>> 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 <<schema-designer.adoc#,Schema Designer>> 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 <<cluster-types.adoc#solrcloud-mode,SolrCloud>>. ==== - == 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] |=== | <<configuring-logging.adoc#logging-screen,Logging Screen>>: Recent log messages and configuration of log levels. +| <<security-ui.adoc#,Security>>: Manage users, roles, and permissions. | <<cloud-screens.adoc#,Cloud Screens>>: Access to SolrCloud node data and status. | <<schema-designer.adoc#,Schema Designer>>: Interactively create a schema using sample data. | <<collections-core-admin.adoc#,Collections / Core Admin>>: Collection or Core management tools. diff --git a/solr/webapp/web/css/angular/menu.css b/solr/webapp/web/css/angular/menu.css index c0d09ec..a89e7ca 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 0000000..2f0a857 --- /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 0000000..8284636 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 0000000..9c3e361 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 0000000..571c16d 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 0000000..e3aa6e3 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 0000000..2bc51ac 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 0000000..37c57b9 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 0000000..c41e5ab 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 0000000..9f6c0f5 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 05e837f..cdf780c 100644 --- a/solr/webapp/web/index.html +++ b/solr/webapp/web/index.html @@ -27,6 +27,7 @@ limitations under the License. <link rel="stylesheet" type="text/css" href="css/angular/common.css?_=${version}"> <link rel="stylesheet" type="text/css" href="css/angular/analysis.css?_=${version}"> <link rel="stylesheet" type="text/css" href="css/angular/schema-designer.css?_=${version}"> + <link rel="stylesheet" type="text/css" href="css/angular/security.css?_=${version}"> <link rel="stylesheet" type="text/css" href="css/angular/cloud.css?_=${version}"> <link rel="stylesheet" type="text/css" href="css/angular/cores.css?_=${version}"> <link rel="stylesheet" type="text/css" href="css/angular/collections.css?_=${version}"> @@ -90,6 +91,7 @@ limitations under the License. <script src="js/angular/controllers/replication.js"></script> <script src="js/angular/controllers/schema.js"></script> <script src="js/angular/controllers/schema-designer.js"></script> + <script src="js/angular/controllers/security.js"></script> <script src="js/angular/controllers/segments.js"></script> <script src="js/angular/controllers/unknown.js"></script> <script src="js/angular/controllers/sqlquery.js"></script> @@ -160,6 +162,8 @@ limitations under the License. </ul> </li> + <li id="security" class="global" ng-class="{active:page=='security'}"><p><a href="#/~security">Security</a></p></li> + <li id="cloud" class="global optional" ng-show="isCloudEnabled" ng-class="{active:showingCloud}"><p><a href="#/~cloud">Cloud</a></p> <ul ng-show="showingCloud"> <li class="nodes" ng-class="{active:page=='cloud-nodes'}"><a href="#/~cloud?view=nodes">Nodes</a></li> diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index 47f0f31..28ba743 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 0000000..93a120e --- /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 4d2c3c3..6da98c2 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 0000000..b3b7b8a --- /dev/null +++ b/solr/webapp/web/partials/security.html @@ -0,0 +1,285 @@ +<!-- +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. +--> +<div id="securityPanel" class="clearfix"> + <div ng-show="isSecurityAdminEnabled && !currentUser"> + <p class="error-msg"><img src="img/ico/prohibition.png"/> Current user is not authenticated! Security panel is disabled.</p> + </div> + + <div ng-show="isSecurityAdminEnabled && !hasSecurityReadPerm"> + <p class="error-msg"><img src="img/ico/prohibition.png"/> You do not have permission to view the security panel.</p> + </div> + + <div ng-show="!isSecurityAdminEnabled"> + <p class="warning-msg"><img src="img/ico/shield--exclamation.png"/> WARNING: Security is not enabled for this server!</p> + <div ng-show="isCloudMode"> + <p class="clearfix">Use the <b>bin/solr auth</b> command-line tool to enable security and then reload this panel. For more information, see: <a target="_blank" href="https://solr.apache.org/guide/authentication-and-authorization-plugins.html#using-security-json-with-solr" class="ref-guide-link">Using security.json with Solr</a></p> + <p class="clearfix"><br/>Example usage of <b>bin/solr auth</b> to enable basic authentication:</p> + <pre> + + + bin/solr auth enable -type basicAuth -prompt true -z {{zkHost}} + + </pre> + </div> + <div ng-show="!isCloudMode"> + <p class="clearfix">Create a <b>security.json</b> config file in your Solr home directory and then restart Solr (on all nodes). For more information, see: <a target="_blank" href="https://solr.apache.org/guide/authentication-and-authorization-plugins.html#using-security-json-with-solr" class="ref-guide-link">Using security.json with Solr</a></p> + </div> + </div> + + <div class="main-col" ng-show="isSecurityAdminEnabled && hasSecurityReadPerm"> + + <div class="block"> + <div id="authn"> + <h2><span>Security Settings</span></h2> + <div id="authn-content"> + <div id="plugins"><span id="tls">TLS enabled? <img ng-show="tls" src="img/ico/tick.png"/><img ng-show="!tls" src="img/ico/cross.png"/></span><span id="authnPlugin">Authentication Plugin: <b>{{authenticationPlugin}}</b></span><span id="authzPlugin">Authorization Plugin: <b>{{authorizationPlugin}}</b></span></div> + <form> + <span ng-show="manageUsersEnabled" id="realm-field"> + <label for="realmName">Realm: </label><input disabled class="input-text" type="text" id="realmName" ng-model="realmName"> + </span> + <span id="block-field"><label for="block_unknown">Block anonymous requests?</label><input class="input-check" type="checkbox" id="block_unknown" ng-model="blockUnknown" ng-change="onBlockUnknownChange()" ng-true-value="'true'" ng-false-value="'false'"/><a ng-click="showHelp('blockUnknownHelp')"><img class="help-ico" src="img/ico/question-white.png"/></a> + <div id="blockUnknownHelp" class="help" ng-show="helpId === 'blockUnknownHelp'"> + <div class="help-top"> + <p>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 <b>role: null</b>. For more information, see: + <div class="help-anchor"><a target="_blank" href="https://solr.apache.org/guide/basic-authentication-plugin.html#enable-basic-authentication">Basic Authentication</a></div></p> + </div> + </div> + </span> + <span ng-show="manageUsersEnabled" id="forward-field"><label for="forward_creds">Forward credentials?</label><input class="input-check" type="checkbox" id="forward_creds" ng-model="forwardCredentials" ng-change="onForwardCredsChange()" ng-true-value="'true'" ng-false-value="'false'"/><a ng-click="showHelp('forwardCredsHelp')"><img class="help-ico" src="img/ico/question-white.png"/></a> + <div id="forwardCredsHelp" class="help" ng-show="helpId === 'forwardCredsHelp'"> + <div class="help-top"> + <p>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: + <div class="help-anchor"><a target="_blank" href="https://solr.apache.org/guide/authentication-and-authorization-plugins.html#pkiauthenticationplugin">PKIAuthenticationPlugin</a></div></p> + </div> + </div> + </span> + </form> + </div> + + <div id="error-dialog" class="error-dialog" ng-show="securityAPIError"> + <div id="error-dialog-note"><p class="clearfix"><img src="img/ico/prohibition.png"/> {{securityAPIError}}</p></div> + <div id="error-dialog-details"><div ng-show="securityAPIErrorDetails"><textarea rows="10" cols="55">{{securityAPIErrorDetails}}</textarea></div></div> + <div id="error-dialog-buttons" class="clearfix"> + <button type="reset" class="error-button" ng-click="closeErrorDialog()"><span>OK</span></button> + </div> + </div> + + <div id="user-dialog" class="dialog" ng-show="showUserDialog" escape-pressed="hideAll()"> + <div id="user-heading" class="heading">{{userDialogHeader}}<button id="delete-user" ng-show="userDialogMode === 'edit'" class="submit" ng-click="confirmDeleteUser()"><span>Delete</span></button></div> + <form autocomplete="off"> + <p class="clearfix"><label for="add_user">Username:</label><input autocomplete="off" ng-disabled="userDialogMode === 'edit'" class="input-text" type="text" id="add_user" ng-model="upsertUser.username" focus-when="showAddField" placeholder="enter a username"></p> + <p class="clearfix"><label for="add_user_roles">Roles:</label><select multiple ng-model="upsertUser.selectedRoles" id="add_user_roles" size="5" ng-options="r for r in roleNames"></select></p> + <p class="clearfix"><label for="add_user">Add New Role:</label><input autocomplete="off" ng-disabled="userDialogMode === 'edit'" class="input-text" type="text" id="add_user_new_role" ng-model="upsertUser.newRole"></p> + <p class="clearfix"><label for="add_user_password">Password:</label><input autocomplete="off" class="input-text" type="password" id="add_user_password" ng-model="upsertUser.password" placeholder="enter a strong password"></p> + <p class="clearfix"><label for="add_user_password2">Confirm password:</label><input autocomplete="off" class="input-text" type="password" id="add_user_password2" ng-model="upsertUser.password2" placeholder="re-enter password"></p> + <div class="formMessageHolder"> + <p class="validate-error" ng-show="validationError"><span>{{validationError}}</span></p> + </div> + <p class="clearfix buttons"> + <button type="submit" class="submit" ng-click="doUpsertUser()"><span>{{userDialogAction}}</span></button> + <button type="reset" class="reset" ng-click="toggleUserDialog()"><span>Cancel</span></button> + </p> + </form> + </div> + + <div id="role-dialog" class="dialog" ng-show="showRoleDialog" escape-pressed="hideAll()"> + <div id="role-heading" class="heading">{{roleDialogHeader}}</div> + <form autocomplete="off"> + <p class="clearfix"><label for="add_role">Name:</label><input autocomplete="off" ng-disabled="roleDialogMode === 'edit'" class="input-text" type="text" id="add_role" ng-model="upsertRole.name" focus-when="showRoleDialog" placeholder="enter role name"></p> + <p class="clearfix"><label for="add_role_users">Users:</label><select multiple ng-model="upsertRole.selectedUsers" id="add_role_users" size="8" ng-options="u for u in userNames"></select></p> + <!-- <p class="clearfix"><label for="add_role_new_user">Add New User:</label><input class="input-text" type="text" id="add_role_new_user" ng-model="upsertRole.newUser"></p> --> + <p class="clearfix"><label for="add_role_perms">Grant Permissions:</label><select multiple ng-model="upsertRole.grantedPerms" id="add_role_perms" size="8" ng-options="p for p in grantPermissionNames"></select></p> + <div class="formMessageHolder"> + <p class="validate-error" ng-show="validationError"><span>{{validationError}}</span></p> + </div> + <p class="clearfix buttons"> + <button type="submit" class="submit" ng-click="doUpsertRole()"><span>{{roleDialogAction}}</span></button> + <button type="reset" class="reset" ng-click="toggleRoleDialog()"><span>Cancel</span></button> + </p> + </form> + </div> + + <div id="add-permission-dialog" class="dialog" ng-show="showPermDialog" escape-pressed="hideAll()"> + <div id="perm-heading" class="heading">{{permDialogHeader}}<button id="delete-perm" ng-show="permDialogMode === 'edit'" class="submit" ng-click="confirmDeletePerm()"><span>Delete</span></button></div> + <form> + <div class="form-field" ng-show="permDialogMode === 'edit'"><label for="add_perm_index">Index:</label><input class="input-text" type="text" id="add_perm_index" ng-model="upsertPerm.index"><a ng-click="showHelp('permIndexHelp')"><img class="help-ico" src="img/ico/question-white.png"/></a> + <div id="permIndexHelp" class="help" ng-show="helpId === 'permIndexHelp'"> + <div class="help-index"> + <p>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. + <div class="help-anchor"><a target="_blank" href="https://solr.apache.org/guide/rule-based-authorization-plugin.html#permission-ordering-and-resolution">Permission Ordering and Resolution</a></div></p> + </div> + </div> + </div> + <div id="perm-select"><label for="predefined">Predefined:</label><select id="predefined" + chosen + ng-change="onPredefinedChanged()" + ng-model="selectedPredefinedPermission" + ng-disabled="permDialogMode === 'edit'" + ng-options="p for p in filteredPredefinedPermissions"></select><span id="add_perm_custom">or Custom: <input ng-disabled="permDialogMode === 'edit'" ng-change="onPredefinedChanged()" type="text" id="add_perm_name" ng-model="upsertPerm.name"><a ng-click="showHelp('permDialogHelp')"><img class="help-ico" src="img/ico/question-white.png"/></a> + <div id="permDialogHelp" class="help" ng-show="helpId === 'permDialogHelp'"> + <div class="help-perm"> + <p>Permissions allow you to grant access to protected resources to one or more roles. Solr provides a list of <b>predefined</b> permissions to cover common use cases, such as collection administration. Otherwise, you can define a <b>custom permission</b> for fine-grained control over the API path(s), collection(s), request method(s) and params. + <div class="help-anchor"><a target="_blank" href="https://solr.apache.org/guide/rule-based-authorization-plugin.html#permissions-2">Rule-based Authorization :: Permissions</a></div></p> + </div> + </div></span> + </div> + <p class="form-field"><label>Roles:</label><select ng-show="manageUserRolesEnabled" multiple ng-model="upsertPerm.selectedRoles" id="add_perm_roles" size="5" ng-options="r for r in roleNamesWithWildcard"></select><input ng-show="!manageUserRolesEnabled" class="input-text" type="text" ng-model="upsertPerm.manualRoles"></p> + <p class="form-field"><label for="add_perm_collection">Collection:</label><input ng-disabled="isPermFieldDisabled" class="input-text" type="text" id="add_perm_collection" ng-model="upsertPerm.collection"></p> + <p class="form-field"><label for="add_perm_path">Path:</label><input ng-disabled="isPermFieldDisabled" type="text" id="add_perm_path" ng-model="upsertPerm.path"></p> + <div class="form-field"><label>Request Method:</label> + <table> + <tr><td><input ng-disabled="isPermFieldDisabled" class="input-check" type="checkbox" id="add_perm_method_get" ng-model="upsertPerm.method.get" ng-true-value="'true'" ng-false-value="'false'"/>GET</td></tr> + <tr><td><input ng-disabled="isPermFieldDisabled" class="input-check" type="checkbox" id="add_perm_method_post" ng-model="upsertPerm.method.post" ng-true-value="'true'" ng-false-value="'false'"/>POST</td></tr> + <tr><td><input ng-disabled="isPermFieldDisabled" class="input-check" type="checkbox" id="add_perm_method_put" ng-model="upsertPerm.method.put" ng-true-value="'true'" ng-false-value="'false'"/>PUT</td></tr> + <tr><td><input ng-disabled="isPermFieldDisabled" class="input-check" type="checkbox" id="add_perm_method_delete" ng-model="upsertPerm.method.delete" ng-true-value="'true'" ng-false-value="'false'"/>DELETE</td></tr> + </table> + </div> + <div class="form-field"><label>Request Params:</label> + <div id="param-rows"> + <div class="row clearfix" ng-repeat="p in params"> + <input class="param-name" type="text" ng-model="p.name" name="paramName" ng-disabled="isPermFieldDisabled"> = <input class="param-value" type="text" ng-model="p.value" name="paramValue" ng-disabled="isPermFieldDisabled"> + <div class="param-buttons" ng-show="!isPermFieldDisabled"> + <a class="rem" ng-click="removeParam($index)"><span></span></a> + <a class="add" ng-click="addParam($index)"><span></span></a> + </div> + </div> + </div> + </div> + <div class="formMessageHolder"> + <p class="validate-error" ng-show="validationError"><span>{{validationError}}</span></p> + </div> + <p class="clearfix buttons"> + <button type="submit" class="submit" ng-click="doUpsertPermission()"><span>{{permDialogAction}}</span></button> + <button type="reset" class="reset" ng-click="togglePermDialog()"><span>Cancel</span></button> + </p> + </form> + </div> + </div> + </div> + + <div class="block"> + <div class="users-left" id="users"> + <h2><span>Users</span></h2> + <p ng-show="!manageUsersEnabled" class="external-msg"><img src="img/ico/prohibition.png"/> Users are managed by an external provider.</p> + <div id="users-content" ng-show="manageUsersEnabled"> + <div id="user-filters"> + Filter users by: <select id="user-filter-type" ng-model="userFilter" ng-change="applyUserFilter()"> + <option value=""></option> + <option value="name">name</option> + <option value="role">role</option> + <option value="path">path</option> + <option value="perm">permission</option> + </select> + <select ng-show="userFilter==='role'||userFilter==='perm'" id="user-filter-options" ng-model="userFilterOption" ng-change="onUserFilterOptionChanged()" ng-options="option for option in userFilterOptions"></select> + <input ng-show="userFilter==='name'||userFilter==='path'" type="text" ng-model="userFilterText" id="user-filter-text" ng-change="onUserFilterTextChanged()"/> + <div id="user-actions"> + <button id="add-user" class="action" ng-click="showAddUserDialog()" ng-show="hasSecurityEditPerm"><span>Add User</span></button> + </div> + </div> + + <div id="users-table"> + <table border="0" cellspacing="0" cellpadding="0"> + <tbody> + <tr ng-class="{odd:$odd}"> + <th class="table-hdr td-name">Username</th> + <th class="table-hdr td-roles">Roles</th> + </tr> + <tr class="editable" ng-repeat="u in filteredUsers" ng-class="{odd:$odd}" ng-click="editUser(u)"> + <td class="table-data">{{u.username}}</td> + <td class="table-data">{{displayList(u.roles)}}</td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + + <div class="roles-right" id="roles"> + <h2><span>Roles</span></h2> + <p ng-show="!manageUserRolesEnabled" class="external-msg"><img src="img/ico/prohibition.png"/> Roles are managed by an external provider.</p> + <div id="roles-content" ng-show="manageUserRolesEnabled"> + <div id="role-filters"> + Filter roles by: <select id="role-filter-type" ng-model="roleFilter" ng-change="applyRoleFilter()"> + <option value=""></option> + <option value="name">name</option> + <option value="user">user</option> + <option value="path">path</option> + <option value="perm">permission</option> + </select> + <select ng-show="roleFilter==='user'||roleFilter==='perm'" id="role-filter-options" ng-model="roleFilterOption" ng-change="onRoleFilterOptionChanged()" ng-options="option for option in roleFilterOptions"></select> + <input ng-show="roleFilter==='name'||roleFilter==='path'" type="text" ng-model="roleFilterText" id="role-filter-text" ng-change="onRoleFilterTextChanged()"/> + <div id="role-actions"> + <button id="add-role" class="action" ng-click="showAddRoleDialog()" ng-show="hasSecurityEditPerm"><span>Add Role</span></button> + </div> + </div> + + <div id="roles-table"> + <table border="0" cellspacing="0" cellpadding="0"> + <tbody> + <tr ng-class="{odd:$odd}"> + <th class="table-hdr td-role">Role</th> + <th class="table-hdr td-roles">Users</th> + </tr> + <tr class="editable" ng-repeat="r in filteredRoles" ng-class="{odd:$odd}" ng-click="editRole(r)"> + <td class="table-data">{{r.name}}</td> + <td class="table-data">{{displayList(r.users)}}</td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> + + <div class="block" id="permissions"> + <h2><span>Permissions</span></h2> + <div id="permissions-content"> + <div id="perm-filters"> + Filter permissions by: <select id="perm-filter-type" ng-model="permFilter" ng-change="applyPermFilter()" ng-options="opt for opt in permFilterTypes"></select> + <select ng-show="permFilter==='role'||permFilter==='user'||permFilter==='collection'" id="perm-filter-options" ng-model="permFilterOption" ng-change="onPermFilterOptionChanged()" ng-options="option for option in permFilterOptions"></select> + <input ng-show="permFilter==='name'||permFilter==='path'" type="text" ng-model="permFilterText" id="perm-filter-text" ng-change="onPermFilterTextChanged()"/> + <div id="perm-actions"> + <button id="add-permission" class="action" ng-click="showAddPermDialog()" ng-show="hasSecurityEditPerm && managePermissionsEnabled"><span>Add Permission</span></button> + </div> + </div> + + <div id="perms-table"> + <table border="0" cellspacing="0" cellpadding="0"> + <tbody> + <tr ng-class="{odd:$odd}"> + <th class="table-hdr td-name">Name</th> + <th class="table-hdr td-roles">Roles</th> + <th class="table-hdr td-coll">Collection</th> + <th class="table-hdr td-path">Path</th> + <th class="table-hdr td-method">Method</th> + <th class="table-hdr td-params">Params</th> + </tr> + <tr class="editable" ng-repeat="p in filteredPerms" ng-class="{odd:$odd}" ng-click="editPerm(p)"> + <td class="table-data">{{p.name}}</td> + <td class="table-data">{{displayRoles(p.roles)}}</td> + <td class="table-data">{{p.collectionNames}}</td> + <td class="table-data">{{displayList(p.paths)}}</td> + <td class="table-data">{{displayList(p.method)}}</td> + <td class="table-data">{{displayParams(p.params)}}</td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> +</div>