Open Distro for Elasticsearch Security Kibana Plugin initial release

This commit is contained in:
Hardik Shah 2019-03-02 20:47:49 -08:00
commit a4d887c01c
156 changed files with 14437 additions and 0 deletions

49
.gitignore vendored Normal file
View File

@ -0,0 +1,49 @@
/package-lock.json
build_stage/
target/
/package.json
/releases
build/
test/
/elasticsearch-security-kibana-plugin.iml
/.idea
/elasticsearch-security-kibana-plugin.iml
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules
jspm_packages
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
.DS_Store

4
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,4 @@
## Code of Conduct
This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
opensource-codeofconduct@amazon.com with any additional questions or comments.

61
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,61 @@
# Contributing Guidelines
Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional
documentation, we greatly value feedback and contributions from our community.
Please read through this document before submitting any issues or pull requests to ensure we have all the necessary
information to effectively respond to your bug report or contribution.
## Reporting Bugs/Feature Requests
We welcome you to use the GitHub issue tracker to report bugs or suggest features.
When filing an issue, please check [existing open](https://github.com/OpenDistro/elasticsearch-security-kibana-plugin/issues), or [recently closed](https://github.com/OpenDistro/elasticsearch-security-kibana-plugin/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already
reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
* A reproducible test case or series of steps
* The version of our code being used
* Any modifications you've made relevant to the bug
* Anything unusual about your environment or deployment
## Contributing via Pull Requests
Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that:
1. You are working against the latest source on the *master* branch.
2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already.
3. You open an issue to discuss any significant work - we would hate for your time to be wasted.
To send us a pull request, please:
1. Fork the repository.
2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change.
3. Ensure local tests pass.
4. Commit to your fork using clear commit messages.
5. Send us a pull request, answering any default questions in the pull request interface.
6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and
[creating a pull request](https://help.github.com/articles/creating-a-pull-request/).
## Finding contributions to work on
Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/OpenDistro/elasticsearch-security-kibana-plugin/labels/help%20wanted) issues is a great place to start.
## Code of Conduct
This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
opensource-codeofconduct@amazon.com with any additional questions or comments.
## Security issue notifications
If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/opendistro_security/vulnerability-reporting/). Please do **not** create a public github issue.
## Licensing
See the [LICENSE](https://github.com/OpenDistro/elasticsearch-security-kibana-plugin/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes.

176
LICENSE Normal file
View File

@ -0,0 +1,176 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

2
NOTICE Normal file
View File

@ -0,0 +1,2 @@
Elasticsearch Security Kibana Plugin
Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.

35
README.md Normal file
View File

@ -0,0 +1,35 @@
# Open Distro For Elasticsearch Security Kibana Plugin
<p align="center">
<img src="<TO BE REPLACED WITH OUR LOGO>" style="width: 60%" class="md_image"/>
</p>
## About this plugin
This plugin for Kibana adds session management and multi-tenancy to your secured cluster.
For Kibana 6.x it also provides a configuration GUI for the security features.
## Downloads
* Kibana 6.x: [Github](https://github.com/mauve-hedgehog/opendistro-elasticsearch-security-kibana-plugin)
## Installation
Download the release matching your Kibana installation, and install it like any other Kibana plugin:
```
bin/kibana-plugin install file:///path/to/opendistro-for-elasticsearch-security-kibana-plugin-<version>.zip
```
## Documentation
### Kibana 6.x
* [Installation](TBD)
* [Authentication](TBD)
* [Multi Tenancy](TBD)
* [Configuration GUI](TBD)
## Copyright
Open Distro For Elasticsearch Security Copyright 2019- Amazon.com, Inc. or its affiliates. All Rights Reserved.

139
build.sh Executable file
View File

@ -0,0 +1,139 @@
#!/bin/bash
KIBANA_VERSION="$1"
ELASTICSEARCH_SECURITY_PLUGIN_VERSION="$2"
COMMAND="$3"
# sanity checks for options
if [ -z "$KIBANA_VERSION" ] || [ -z "$ELASTICSEARCH_SECURITY_PLUGIN_VERSION" ] || [ -z "$COMMAND" ]; then
echo "Usage: ./build.sh <kibana_version> <elasticsearch_security_plugin_version> <install|deploy>"
exit 1;
fi
if [ "$COMMAND" != "deploy" ] && [ "$COMMAND" != "install" ]; then
echo "Usage: ./build.sh <kibana_version> <elasticsearch_security_plugin_version> <install|deploy>"
echo "Unknown command: $COMMAND"
exit 1;
fi
# sanity checks for maven
if [ -z "$MAVEN_HOME" ]; then
echo "MAVEN_HOME not set"
exit 1;
fi
echo "+++ Checking Maven version +++"
$MAVEN_HOME/bin/mvn -version
if [ $? != 0 ]; then
echo "Checking maven version failed";
exit 1;
fi
# sanity checks for nvm
if [ -z "$NVM_HOME" ]; then
echo "NVM_HOME not set"
exit 1;
fi
echo "+++ Sourcing nvm +++"
[ -s "$NVM_HOME/nvm.sh" ] && \. "$NVM_HOME/nvm.sh"
echo "+++ Checking nvm version +++"
nvm version
if [ $? != 0 ]; then
echo "Checking mvn version failed";
exit 1;
fi
# check version matches. Do not use jq here, only bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd $DIR
# while read -r line
# do
# if [[ "$line" =~ ^\"version\".* ]]; then
# if [[ "$line" != "\"version\": \"$1-$2\"," ]]; then
# echo "Provided version \"version\": \"$1-$2\" does not match Kibana version: $line"
# exit 1;
# fi
# fi
# done < "package.json"
# cleanup any leftovers
./clean.sh
if [ $? != 0 ]; then
echo "Cleaning leftovers failed";
exit 1;
fi
# prepare artefacts
PLUGIN_NAME="opendistro_security_kibana_plugin-$ELASTICSEARCH_SECURITY_PLUGIN_VERSION"
echo "+++ Building $PLUGIN_NAME.zip +++"
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "$DIR"
mkdir -p build_stage
cd build_stage
echo "+++ Cloning https://github.com/mauve-hedgehog/opendistro-elasticsearch-kibana.git +++"
git clone https://github.com/mauve-hedgehog/opendistro-elasticsearch-kibana.git || true > /dev/null 2>&1
if [ $? != 0 ]; then
echo "got clone Kibana repository failed";
exit 1;
fi
cd "opendistro-elasticsearch-kibana"
git fetch
echo "+++ Change to tags/v$KIBANA_VERSION +++"
git checkout "tags/v$KIBANA_VERSION"
if [ $? != 0 ]; then
echo "Switching to Kibana tags/v$KIBANA_VERSION failed";
exit 1;
fi
echo "+++ Installing node version $(cat .node-version) +++"
nvm install "$(cat .node-version)"
if [ $? != 0 ]; then
echo "Installing node $(cat .node-version) failed";
exit 1;
fi
cd "$DIR"
rm -rf build/
rm -rf node_modules/
echo "+++ Installing node modules +++"
npm install
if [ $? != 0 ]; then
echo "Installing node modules failed";
exit 1;
fi
echo "+++ Copy plugin contents +++"
COPYPATH="build/kibana/$PLUGIN_NAME"
mkdir -p "$COPYPATH"
cp -a "$DIR/index.js" "$COPYPATH"
cp -a "$DIR/package.json" "$COPYPATH"
cp -a "$DIR/lib" "$COPYPATH"
cp -a "$DIR/node_modules" "$COPYPATH"
cp -a "$DIR/public" "$COPYPATH"
if [ "$COMMAND" = "deploy" ] ; then
echo "+++ mvn clean deploy -Prelease +++"
$MAVEN_HOME/bin/mvn clean deploy -Prelease
if [ $? != 0 ]; then
echo "$MAVEN_HOME/bin/mvn clean deploy -Prelease failed";
exit 1;
fi
fi
if [ "$COMMAND" = "install" ] ; then
echo "+++ mvn clean install +++"
$MAVEN_HOME/bin/mvn clean install
if [ $? != 0 ]; then
echo "$MAVEN_HOME/bin/mvn clean install failed";
exit 1;
fi
fi

10
clean.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd $DIR
find . -name '.DS_Store' -type f -delete
rm -rf ./target
rm -rf ./releases
rm -rf ./node_modules
rm -rf ./build
rm -f *.log

446
index.js Normal file
View File

@ -0,0 +1,446 @@
const pluginRoot = require('requirefrom')('');
import { resolve, join, sep } from 'path';
import { has } from 'lodash';
import indexTemplate from './lib/elasticsearch/setup_index_template';
import { migrateTenants } from './lib/multitenancy/migrate_tenants';
export default function (kibana) {
let APP_ROOT;
let API_ROOT;
let authenticationBackend;
let securityConfiguration;
return new kibana.Plugin({
name: 'opendistro_security',
id: 'opendistro_security',
require: ['kibana', 'elasticsearch'],
config: function (Joi) {
var obj = Joi.object({
enabled: Joi.boolean().default(true),
allow_client_certificates: Joi.boolean().default(false),
readonly_mode: Joi.object().keys({
roles: Joi.array().default([]),
}).default(),
cookie: Joi.object().keys({
secure: Joi.boolean().default(false),
name: Joi.string().default('security_authentication'),
password: Joi.string().min(32).default('security_cookie_default_password'),
ttl: Joi.number().integer().min(0).default(60 * 60 * 1000),
}).default(),
session: Joi.object().keys({
ttl: Joi.number().integer().min(0).default(60 * 60 * 1000),
keepalive: Joi.boolean().default(true),
}).default(),
auth: Joi.object().keys({
type: Joi.string().valid(['', 'basicauth', 'jwt', 'openid', 'saml', 'proxy', 'kerberos', 'proxycache']).default(''),
anonymous_auth_enabled: Joi.boolean().default(false),
unauthenticated_routes: Joi.array().default(["/api/status"]),
}).default(),
basicauth: Joi.object().keys({
enabled: Joi.boolean().default(true),
unauthenticated_routes: Joi.array().default(["/api/status"]),
forbidden_usernames: Joi.array().default([]),
header_trumps_session: Joi.boolean().default(false),
alternative_login: Joi.object().keys({
headers: Joi.array().default([]),
show_for_parameter: Joi.string().allow('').default(''),
valid_redirects: Joi.array().default([]),
button_text: Joi.string().default('Login with provider'),
buttonstyle: Joi.string().allow('').default("")
}).default(),
loadbalancer_url: Joi.string().allow('', null).default(null),
login: Joi.object().keys({
title: Joi.string().allow('').default('Please login to Kibana'),
subtitle: Joi.string().allow('').default('If you have forgotten your username or password, please ask your system administrator'),
showbrandimage: Joi.boolean().default(true),
brandimage: Joi.string().default("/plugins/opendistro_security/assets/open_distro_for_elasticsearch_logo_h.svg"),
buttonstyle: Joi.string().allow('').default("")
}).default(),
}).default(),
multitenancy: Joi.object().keys({
enabled: Joi.boolean().default(false),
show_roles: Joi.boolean().default(false),
enable_filter: Joi.boolean().default(false),
debug: Joi.boolean().default(false),
tenants: Joi.object().keys({
enable_private: Joi.boolean().default(true),
enable_global: Joi.boolean().default(true),
preferred: Joi.array(),
}).default(),
}).default(),
configuration: Joi.object().keys({
enabled: Joi.boolean().default(true)
}).default(),
accountinfo: Joi.object().keys({
enabled: Joi.boolean().default(false)
}).default(),
openid: Joi.object().keys({
connect_url: Joi.string(),
header: Joi.string().default('Authorization'),
client_id: Joi.string(),
client_secret: Joi.string().allow('').default(''),
scope: Joi.string().default('openid profile email address phone'),
base_redirect_url: Joi.string().allow('').default(''),
logout_url: Joi.string().allow('').default(''),
root_ca: Joi.string().allow('').default(''),
verify_hostnames: Joi.boolean().default(true)
}).default().when('auth.type', {
is: 'openid',
then: Joi.object({
client_id: Joi.required(),
connect_url: Joi.required()
})
}),
proxycache: Joi.object().keys({
user_header: Joi.string(),
roles_header: Joi.string(),
login_endpoint: Joi.string().allow('', null).default(null),
}).default().when('auth.type', {
is: 'proxycache',
then: Joi.object({
user_header: Joi.required(),
roles_header: Joi.required()
})
}),
jwt: Joi.object().keys({
enabled: Joi.boolean().default(false),
login_endpoint: Joi.string(),
url_param: Joi.string().default('authorization'),
header: Joi.string().default('Authorization')
}).default()
}).default();
return obj;
},
deprecations: function () {
return [
(settings, log) => {
if (has(settings, 'basicauth.enabled')) {
log('Config key "opendistro_security.basicauth.enabled" is deprecated. Please use "opendistro_security.auth.type" instead.');
}
if (has(settings, 'jwt.enabled')) {
log('Config key "opendistro_security.jwt.enabled" is deprecated. Please use "opendistro_security.auth.type" instead.');
}
}
];
},
uiExports: {
hacks: [
'plugins/opendistro_security/chrome/readonly/enable_readonly',
'plugins/opendistro_security/chrome/multitenancy/enable_multitenancy',
'plugins/opendistro_security/chrome/accountinfo/enable_accountinfo',
'plugins/opendistro_security/chrome/logout_button',
'plugins/opendistro_security/chrome/configuration/enable_configuration',
'plugins/opendistro_security/services/access_control',
'plugins/opendistro_security/customizations/enable_customizations.js'
],
replaceInjectedVars: async function(originalInjectedVars, request, server) {
const authType = server.config().get('opendistro_security.auth.type');
// Make sure securityDynamic is always available to the frontend, no matter what
let securityDynamic = {};
let userInfo = null;
try {
// If the user is authenticated, just get the regular values
if(request.auth.securitySessionStorage.isAuthenticated()) {
let sessionCredentials = request.auth.securitySessionStorage.getSessionCredentials();
userInfo = {
username: sessionCredentials.username,
isAnonymousAuth: sessionCredentials.isAnonymousAuth
};
} else if (['', 'kerberos', 'proxy'].indexOf(authType) > -1) {
// We should be able to use this with kerberos and proxy too
try {
let authInfo = await request.auth.securitySessionStorage.getAuthInfo();
userInfo = {
username: authInfo.user_name
};
} catch(error) {
// Not authenticated, so don't do anything
}
}
if (userInfo) {
securityDynamic.user = userInfo;
}
} catch (error) {
// Don't to anything here.
// If there's an error, it's probably because x-pack security is enabled.
}
return {
...originalInjectedVars,
securityDynamic
}
},
apps: [
{
id: 'security-login',
title: 'Login',
main: 'plugins/opendistro_security/apps/login/login',
hidden: true,
auth: false
},
{
id: 'security-customerror',
title: 'CustomError',
main: 'plugins/opendistro_security/apps/customerror/customerror',
hidden: true,
auth: false
},
{
id: 'security-multitenancy',
title: 'Tenants',
main: 'plugins/opendistro_security/apps/multitenancy/multitenancy',
hidden: false,
auth: true,
order: 9010,
icon: 'plugins/opendistro_security/assets/networking.svg',
linkToLastSubUrl: false,
url: '/app/security-multitenancy#/'
},
{
id: 'security-accountinfo',
title: 'Account',
main: 'plugins/opendistro_security/apps/accountinfo/accountinfo',
hidden: false,
auth: true,
order: 9020,
icon: 'plugins/opendistro_security/assets/info.svg',
linkToLastSubUrl: false,
url: '/app/security-accountinfo#/'
},
{
id: 'security-configuration',
title: 'Security',
main: 'plugins/opendistro_security/apps/configuration/configuration',
order: 9009,
auth: true,
icon: 'plugins/opendistro_security/assets/opendistro_security.svg',
linkToLastSubUrl: false,
url: '/app/security-configuration#/'
}
],
chromeNavControls: [
'plugins/opendistro_security/chrome/btn_logout/btn_logout.js'
]
,
injectDefaultVars(server, options) {
options.multitenancy_enabled = server.config().get('opendistro_security.multitenancy.enabled');
options.accountinfo_enabled = server.config().get('opendistro_security.accountinfo.enabled');
options.basicauth_enabled = server.config().get('opendistro_security.basicauth.enabled');
options.kibana_index = server.config().get('kibana.index');
options.kibana_server_user = server.config().get('elasticsearch.username');
return options;
}
},
init(server, options) {
APP_ROOT = '';
API_ROOT = `${APP_ROOT}/api/v1`;
const config = server.config();
// If X-Pack is installed it needs to be disabled for Security to run.
try {
let xpackInstalled = false;
Object.keys(server.plugins).forEach((plugin) => {
if (plugin.toLowerCase().indexOf('xpack') > -1) {
xpackInstalled = true;
}
});
if (xpackInstalled && config.get('xpack.opendistro_security.enabled') !== false) {
// It seems like X-Pack is installed and enabled, so we show an error message and then exit.
this.status.red("X-Pack Security needs to be disabled for Security to work properly. Please set 'xpack.opendistro_security.enabled' to false in your kibana.yml");
return false;
}
} catch (error) {
server.log(['error', 'security'], `An error occurred while making sure that X-Pack isn't enabled`);
}
// all your routes are belong to us
require('./lib/auth/routes_authinfo')(pluginRoot, server, this, APP_ROOT, API_ROOT);
// provides authentication methods against Security
const BackendClass = pluginRoot(`lib/backend/opendistro_security`);
const securityBackend = new BackendClass(server, server.config);
server.expose('getSecurityBackend', () => securityBackend);
// provides configuration methods against Security
const ConfigurationBackendClass = pluginRoot(`lib/configuration/backend/opendistro_security_configuration_backend`);
const securityConfigurationBackend = new ConfigurationBackendClass(server, server.config);
server.expose('getSecurityConfigurationBackend', () => securityConfigurationBackend);
server.register([require('hapi-async-handler')]);
let authType = config.get('opendistro_security.auth.type');
let authClass = null;
// For legacy code
if (! authType) {
if (config.get('opendistro_security.basicauth.enabled')) {
authType = 'basicauth';
} else if(config.get('opendistro_security.jwt.enabled')) {
authType = 'jwt';
}
// Dynamically update the auth.type to make it available to the frontend
if (authType) {
config.set('opendistro_security.auth.type', authType);
}
}
// Set up the storage cookie
server.state('security_storage', {
path: '/',
ttl: null, // Cookie deleted when the browser is closed
password: config.get('opendistro_security.cookie.password'),
encoding: 'iron',
isSecure: config.get('opendistro_security.cookie.secure'),
});
if (authType && authType !== '' && ['basicauth', 'jwt', 'openid', 'saml', 'proxycache'].indexOf(authType) > -1) {
server.register([
require('hapi-auth-cookie'),
], (error) => {
if (error) {
server.log(['error', 'security'], `An error occurred registering server plugins: ${error}`);
this.status.red('An error occurred during initialisation, please check the logs.');
return;
}
this.status.yellow('Initialising Security authentication plugin.');
if (config.get("opendistro_security.cookie.password") == 'security_cookie_default_password') {
this.status.yellow("Default cookie password detected, please set a password in kibana.yml by setting 'opendistro_security.cookie.password' (min. 32 characters).");
}
if (!config.get("opendistro_security.cookie.secure")) {
this.status.yellow("'opendistro_security.cookie.secure' is set to false, cookies are transmitted over unsecure HTTP connection. Consider using HTTPS and set this key to 'true'");
}
if (authType === 'openid') {
let OpenId = require('./lib/auth/types/openid/OpenId');
authClass = new OpenId(pluginRoot, server, this, APP_ROOT, API_ROOT);
} else if (authType == 'basicauth') {
let BasicAuth = require('./lib/auth/types/basicauth/BasicAuth');
authClass = new BasicAuth(pluginRoot, server, this, APP_ROOT, API_ROOT);
} else if (authType == 'jwt') {
let Jwt = require('./lib/auth/types/jwt/Jwt');
authClass = new Jwt(pluginRoot, server, this, APP_ROOT, API_ROOT);
this.status.yellow("Security copy JWT params registered.");
} else if (authType == 'saml') {
let Saml = require('./lib/auth/types/saml/Saml');
authClass = new Saml(pluginRoot, server, this, APP_ROOT, API_ROOT);
} else if (authType == 'proxycache') {
let ProxyCache = require('./lib/auth/types/proxycache/ProxyCache');
authClass = new ProxyCache(pluginRoot, server, this, APP_ROOT, API_ROOT);
}
if (authClass) {
authClass.init();
this.status.yellow('Security session management enabled.');
}
});
} else {
// Register the storage plugin for the other auth types
server.register({
register: pluginRoot('lib/session/sessionPlugin'),
options: {
authType: null,
}
})
}
if (authType != 'jwt') {
this.status.yellow("Security copy JWT params disabled");
}
if (config.get('opendistro_security.multitenancy.enabled')) {
// sanity check - header whitelisted?
var headersWhitelist = config.get('elasticsearch.requestHeadersWhitelist');
if (headersWhitelist.indexOf('securitytenant') == -1) {
this.status.red('No tenant header found in whitelist. Please add securitytenant to elasticsearch.requestHeadersWhitelist in kibana.yml');
return;
}
if (config.has('xpack.spaces.enabled') && config.get('xpack.spaces.enabled')) {
this.status.red('At the moment it is not possible to have both Spaces and multitenancy enabled. Please set xpack.spaces.enabled to false.');
return;
}
require('./lib/multitenancy/routes')(pluginRoot, server, this, APP_ROOT, API_ROOT);
require('./lib/multitenancy/headers')(pluginRoot, server, this, APP_ROOT, API_ROOT, authClass);
server.state('security_preferences', {
ttl: 2217100485000,
path: '/',
isSecure: false,
isHttpOnly: false,
clearInvalid: true, // remove invalid cookies
strictHeader: true, // don't allow violations of RFC 6265
encoding: 'iron',
password: config.get("opendistro_security.cookie.password")
});
this.status.yellow("Security multitenancy registered.");
} else {
this.status.yellow("Security multitenancy disabled");
}
// Assign auth header after MT
if (authClass) {
authClass.registerAssignAuthHeader();
}
if (config.get('opendistro_security.configuration.enabled')) {
require('./lib/configuration/routes/routes')(pluginRoot, server, APP_ROOT, API_ROOT);
this.status.yellow("Routes for Security configuration GUI registered.");
} else {
this.status.yellow("Security configuration GUI disabled");
}
// create index template for tenant indices
if(config.get('opendistro_security.multitenancy.enabled')) {
const { setupIndexTemplate, waitForElasticsearchGreen } = indexTemplate(this, server);
//const {migrateTenants} = tenantMigrator(this, server);
waitForElasticsearchGreen().then( () => {
this.status.yellow('Setting up index template.');
setupIndexTemplate();
migrateTenants(server)
.then( () => {
this.status.green('Tenant indices migrated.');
})
.catch((error) => {
this.status.yellow('Tenant indices migration failed');
});
});
} else {
this.status.green('Security plugin initialised.');
}
// Using an admin certificate may lead to unintended consequences
if ((typeof config.get('elasticsearch.ssl.certificate') !== 'undefined' && typeof config.get('elasticsearch.ssl.certificate') !== false) && config.get('opendistro_security.allow_client_certificates') !== true) {
this.status.red("'elasticsearch.ssl.certificate' can not be used without setting 'opendistro_security.allow_client_certificates' to 'true' in kibana.yml. Please refer to the documentation for more information about the implications of doing so.");
}
}
});
};

View File

@ -0,0 +1,40 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
export default class AuthenticationError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
}
}

View File

@ -0,0 +1,41 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
export default class InvalidSessionError extends Error {
constructor(message, inner) {
super(message);
this.name = this.constructor.name;
this.inner = inner;
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
export default class MissingRoleError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
export default class MissingTenantError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
export default class SessionExpiredError extends Error {
constructor(message, inner) {
super(message);
this.name = this.constructor.name;
this.inner = inner;
}
}

View File

@ -0,0 +1,20 @@
import _ from 'lodash';
export default function (originalHeaders, headersToKeep) {
const normalizeHeader = function (header) {
if (!header) {
return '';
}
header = header.toString();
return header.trim().toLowerCase();
};
const headersToKeepNormalized = headersToKeep.map(normalizeHeader);
const originalHeadersNormalized = _.mapKeys(originalHeaders, function (headerValue, headerName) {
return normalizeHeader(headerName);
});
return _.pick(originalHeaders, headersToKeepNormalized);
}

58
lib/auth/parseNextUrl.js Normal file
View File

@ -0,0 +1,58 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import { parse } from 'url';
/**
*
* @param nextUrl - Only the query parameter value, instead of the complete url. We don't always have the full url.
* @param basePath
* @returns {*}
*/
export function parseNextUrl(nextUrl, basePath) {
// check forgery of protocol, hostname, port, pathname
const { protocol, hostname, port, pathname, hash } = parse(decodeURIComponent(nextUrl), true, true);
// If we have a relative protocol, hostname is reported as an empty string, so we need to make sure it is null
if (protocol !== null || hostname !== null || port !== null) {
return `${basePath}/`;
}
// We always need the base path
if (!String(pathname).startsWith(basePath)) {
return `${basePath}/${nextUrl}`;
}
// All valid
return nextUrl
}

141
lib/auth/routes.js Normal file
View File

@ -0,0 +1,141 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import Boom from 'boom';
import Joi from 'joi';
import { isEmpty } from 'lodash';
module.exports = function (pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
const AuthenticationError = pluginRoot('lib/auth/errors/authentication_error');
const config = server.config();
const sessionTTL = config.get('opendistro_security.session.ttl');
const loginApp = server.getHiddenUiAppById('security-login');
/**
* The login page.
*/
server.route({
method: 'GET',
path: `${APP_ROOT}/login`,
handler(request, reply) {
return reply.renderAppWithDefaultConfig(loginApp);
},
config: {
auth: false
}
});
server.route({
method: 'POST',
path: `${API_ROOT}/auth/login`,
handler: {
async: async (request, reply) => {
try {
// In order to prevent direct access for certain usernames (e.g. service users like
// kibanaserver, logstash etc.) we can add them to basicauth.forbidden_usernames.
// If the username in the payload matches an item in the forbidden array, we throw an AuthenticationError
const basicAuthConfig = server.config().get('opendistro_security.basicauth');
if (basicAuthConfig.forbidden_usernames && basicAuthConfig.forbidden_usernames.length) {
if (request.payload && request.payload.username && basicAuthConfig.forbidden_usernames.indexOf(request.payload.username) > -1) {
throw new AuthenticationError('Invalid username or password');
}
}
let user = await server.plugins.opendistro_security.getSecurityBackend().authenticate(request.payload);
let session = {
username: user.username,
credentials: user.credentials,
proxyCredentials: user.proxyCredentials
};
if (sessionTTL) {
session.expiryTime = Date.now() + sessionTTL;
}
request.auth.session.set(session);
// handle tenants if MT is enabled
if(server.config().get("opendistro_security.multitenancy.enabled")) {
// get the preferred tenant of the user
let globalTenantEnabled = server.config().get("opendistro_security.multitenancy.tenants.enable_global");
let privateTenantEnabled = server.config().get("opendistro_security.multitenancy.tenants.enable_private");
let preferredTenants = server.config().get("opendistro_security.multitenancy.tenants.preferred");
let finalTenant = server.plugins.opendistro_security.getSecurityBackend().getTenantByPreference(request, user.username, user.tenants, preferredTenants, globalTenantEnabled, privateTenantEnabled);
return reply({
username: user.username,
tenants: user.tenants,
roles: user.roles,
backendroles: user.backendroles,
selectedTenant: user.selectedTenant,
}).state('security_tenant', finalTenant);
} else {
// no MT, nothing more to do
return reply({
username: user.username,
tenants: user.tenants
});
}
} catch (error) {
if (error instanceof AuthenticationError) {
return reply(Boom.unauthorized(error.message));
} else {
return reply(Boom.badImplementation(error.message));
}
}
}
},
config: {
validate: {
payload: {
username: Joi.string().required(),
password: Joi.string().required()
}
},
auth: false
}
});
server.route({
method: 'POST',
path: `${API_ROOT}/auth/logout`,
handler: (request, reply) => {
request.auth.session.clear();
reply({}).unstate('security_tenant');
},
config: {
auth: false
}
});
}; //end module

View File

@ -0,0 +1,53 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import Boom from 'boom';
import Joi from 'joi';
module.exports = function (pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
server.route({
method: 'GET',
path: `${API_ROOT}/auth/authinfo`,
handler: (request, reply) => {
try {
let authinfo = server.plugins.opendistro_security.getSecurityBackend().authinfo(request.headers);
return reply(authinfo);
} catch(error) {
if (error.isBoom) {
return reply(error);
}
throw error;
}
}
});
}; //end module

308
lib/auth/types/AuthType.js Normal file
View File

@ -0,0 +1,308 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import { assign } from 'lodash';
import Boom from 'boom';
import InvalidSessionError from "../errors/invalid_session_error";
import SessionExpiredError from "../errors/session_expired_error";
export default class AuthType {
constructor(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
this.pluginRoot = pluginRoot;
this.server = server;
this.kbnServer = kbnServer;
this.APP_ROOT = APP_ROOT;
this.API_ROOT = API_ROOT;
this.config = server.config();
this.basePath = this.config.get('server.basePath');
this.unauthenticatedRoutes = this.config.get('opendistro_security.auth.unauthenticated_routes');
this.sessionTTL = this.config.get('opendistro_security.session.ttl');
this.sessionKeepAlive = this.config.get('opendistro_security.session.keepalive');
/**
* The authType is saved in the auth cookie for later reference
* @type {string}
*/
this.type = null;
/**
* Tells the sessionPlugin whether or not to validate the number of tenants when authenticating
* @type {boolean}
*/
this.validateAvailableTenants = true;
/**
* The name of the header were we look for an authorization value.
* This should most likely be set in the subclass depending on a config value.
* @type {string}
*/
this.authHeaderName = 'authorization';
}
init() {
this.setupStorage();
this.setupAuthScheme();
this.setupRoutes();
}
setupStorage() {
this.server.register({
register: this.pluginRoot('lib/session/sessionPlugin'),
options: {
authType: this.type,
authHeaderName: this.authHeaderName,
authenticateFunction: this.authenticate.bind(this),
validateAvailableTenants: this.validateAvailableTenants
}
})
}
getCookieConfig() {
const cookieConfig = {
password: this.config.get('opendistro_security.cookie.password'),
cookie: this.config.get('opendistro_security.cookie.name'),
isSecure: this.config.get('opendistro_security.cookie.secure'),
validateFunc: this.sessionValidator(this.server),
clearInvalid: true,
ttl: this.config.get('opendistro_security.cookie.ttl')
};
return cookieConfig;
}
/**
* Returns the auth header needed for the Security backend
* @param session
* @returns {*}
*/
getAuthHeader(session) {
if (session.credentials && session.credentials.authHeaderValue) {
return {
[this.authHeaderName]: session.credentials.authHeaderValue
}
}
return false;
}
/**
* Checks if we have an authorization header.
*
* Pass the existing session credentials to compare with the authorization header.
*
* @param request
* @param sessionCredentials
* @returns {object|null} - credentials for the authentication
*/
detectAuthHeaderCredentials(request, sessionCredentials = null) {
if (request.headers[this.authHeaderName]) {
const authHeaderValue = request.headers[this.authHeaderName];
// If we have sessionCredentials AND auth headers we need to check if they are the same.
if (sessionCredentials !== null && sessionCredentials.authHeaderValue === authHeaderValue) {
// The auth header credentials are the same as those in the session,
// no need to return new credentials so we're just nulling the token here
return null;
}
return {
authHeaderValue: authHeaderValue
}
}
return null;
}
authenticate(credentials) {
throw new Error('The authenticate method must be implemented by the sub class');
}
onUnAuthenticated(request, reply) {
throw new Error('The onUnAuthenticated method must be implemented by the sub class');
}
setupRoutes() {
throw new Error('The getAuthHeader method must be implemented by the sub class');
}
setupAuthScheme() {
this.server.auth.strategy('security_access_control_cookie', 'cookie', false, this.getCookieConfig());
this.server.auth.scheme('security_access_control_scheme', (server, options) => ({
authenticate: (request, reply) => {
// let configured routes that are not under our control pass,
// for example /api/status to check Kibana status without a logged in user
if (this.unauthenticatedRoutes.includes(request.path)) {
var credentials = this.server.plugins.opendistro_security.getSecurityBackend().getServerUser();
reply.continue({credentials});
return;
};
this.server.auth.test('security_access_control_cookie', request, async(error, credentials) => {
if (error) {
let authHeaderCredentials = this.detectAuthHeaderCredentials(request);
if (authHeaderCredentials) {
try {
let {session} = await request.auth.securitySessionStorage.authenticate(authHeaderCredentials);
// Returning the session equals setting the values with hapi-auth-cookie@set()
return reply.continue({
// Watch out here - hapi-auth-cookie requires us to send back an object with credentials
// as a key. Otherwise other values than the credentials will be overwritten
credentials: session
});
} catch (authError) {
return this.onUnAuthenticated(request, reply, authError);
}
}
if (request.headers) {
// If the session has expired, we may receive ajax requests that can't handle a 302 redirect.
// In this case, we trigger a 401 and let the interceptor handle the redirect on the client side.
if ((request.headers.accept && request.headers.accept.split(',').indexOf('application/json') > -1)
|| (request.headers['content-type'] && request.headers['content-type'].indexOf('application/json') > -1)) {
return reply({message: 'Session expired', redirectTo: 'login'}).code(401);
}
// Cookie auth failed, user is not authenticated
return this.onUnAuthenticated(request, reply, error);
}
}
// credentials are everything that is in the auth cookie
reply.continue(credentials);
});
}
}));
// Activates hapi-auth-cookie for ALL routes, unless
// a) the route is listed in "unauthenticatedRoutes" or
// b) the auth option in the route definition is explicitly set to false
this.server.auth.strategy('security_access_control', 'security_access_control_scheme', true);
}
/**
* If a session auth cookie exists, the sessionValidator is called to validate the content
* @param server
* @returns {validate}
*/
sessionValidator(server) {
let validate = async(request, session, callback) => {
if (session.authType !== this.type) {
return callback(new InvalidSessionError('Invalid session'), false, null);
}
// Check if we have auth header credentials set that are different from the session credentials
let differentAuthHeaderCredentials = this.detectAuthHeaderCredentials(request, session.credentials);
if (differentAuthHeaderCredentials) {
try {
let authResponse = await request.auth.securitySessionStorage.authenticate(differentAuthHeaderCredentials);
return callback(null, true, {credentials: authResponse.session});
} catch(error) {
request.auth.securitySessionStorage.clearStorage();
return callback(error, false);
}
}
// If we are still here, we need to compare the expiration time
// JWT's .exp is denoted in seconds, not milliseconds.
if (session.exp && session.exp < Math.floor(Date.now() / 1000)) {
request.auth.securitySessionStorage.clearStorage();
return callback(new SessionExpiredError('Session expired.'), false);
} else if (!session.exp && this.sessionTTL) {
if (!session.expiryTime || session.expiryTime < Date.now()) {
request.auth.securitySessionStorage.clearStorage();
return callback(new SessionExpiredError('Session expired.'), false);
}
if (this.sessionKeepAlive) {
session.expiryTime = Date.now() + this.sessionTTL;
// According to the documentation, returning the session in the cookie
// should be equivalent to calling request.auth.session.set(),
// but it seems like the cookie's browser lifetime isn't updated.
// Hence, we need to set it explicitly.
request.auth.session.set(session);
}
}
// All good, return the session as it was
return callback(null, true, {credentials: session});
};
return validate;
}
/**
* Add credential headers to the passed request.
* @param request
*/
assignAuthHeader(request) {
if (! request.headers[this.authHeaderName]) {
const session = request.state[this.config.get('opendistro_security.cookie.name')];
if (session && session.credentials) {
try {
let authHeader = this.getAuthHeader(session);
if (authHeader !== false) {
assign(request.headers, authHeader);
}
} catch (error) {
this.server.log(['security', 'error'], `An error occurred while computing auth headers, clearing session: ${error}`);
request.auth.securitySessionStorage.clear();
throw error;
}
}
}
}
/**
* Called on each authenticated request.
* Used to add the credentials header to the request.
*/
registerAssignAuthHeader() {
this.server.ext('onPreAuth', (request, next) => {
try {
this.assignAuthHeader(request);
} catch(error) {
return next.redirect(this.basePath + '/customerror?type=authError');
}
return next.continue();
});
}
}

View File

@ -0,0 +1,150 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import AuthType from "../AuthType";
import MissingRoleError from "../../errors/missing_role_error";
export default class BasicAuth extends AuthType {
constructor(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
super(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT);
/**
* The authType is saved in the auth cookie for later reference
* @type {string}
*/
this.type = 'basicauth';
/**
* The name of the authorization header to be used
* @type {string}
*/
this.authHeaderName = 'authorization';
/**
* Redirect to a loadbalancer url instead of a relative path when unauthenticated?
* @type {boolean}
*/
this.loadBalancerURL = this.config.get('opendistro_security.basicauth.loadbalancer_url');
/**
* Allow anonymous access?
* @type {boolean}
*/
this.anonymousAuthEnabled = this.config.get('opendistro_security.auth.anonymous_auth_enabled');
}
/**
* Checks if we have an authorization header.
*
* Pass the existing session credentials to compare with the authorization header.
*
* @param request
* @param sessionCredentials
* @returns {object|null} - credentials for the authentication
*/
detectAuthHeaderCredentials(request, sessionCredentials = null) {
if (request.headers[this.authHeaderName]) {
const authHeaderValue = request.headers[this.authHeaderName];
const headerTrumpsSession = this.config.get('opendistro_security.basicauth.header_trumps_session');
// If we have sessionCredentials AND auth headers we need to check if they are the same.
if (sessionCredentials !== null && sessionCredentials.authHeaderValue === authHeaderValue) {
// The auth header credentials are the same as those in the session,
// no need to return new credentials so we're just nulling the token here
return null;
}
// We may have an auth header for a different user than the user saved in the session.
// To avoid confusion, we do NOT override the cookie user, unless explicitly configured to do so.
if (sessionCredentials !== null && ! headerTrumpsSession) {
return null;
}
return {
authHeaderValue: authHeaderValue
}
}
return null;
}
async authenticate(credentials, options = {}) {
// A login can happen via a POST request (login form) or when we have request headers with user credentials.
// We also need to re-authenticate if the credentials (headers) don't match what's in the session.
try {
let user = await this.server.plugins.opendistro_security.getSecurityBackend().authenticateWithHeader(this.authHeaderName, credentials.authHeaderValue);
let session = {
username: user.username,
credentials: credentials,
authType: this.type,
isAnonymousAuth: (options && options.isAnonymousAuth === true) ? true : false
};
if(this.sessionTTL) {
session.expiryTime = Date.now() + this.sessionTTL
}
return {
session,
user
}
} catch(error) {
throw error;
}
}
onUnAuthenticated(request, reply, error) {
if (error instanceof MissingRoleError) {
return reply.redirect(this.basePath + '/customerror?type=missingRole')
}
const nextUrl = encodeURIComponent(request.url.path);
if (this.anonymousAuthEnabled) {
return reply.redirect(`${this.basePath}${this.APP_ROOT}/auth/anonymous?nextUrl=${nextUrl}`);
}
if (this.loadBalancerURL) {
return reply.redirect(`${this.loadBalancerURL}${this.basePath}${this.APP_ROOT}/login?nextUrl=${nextUrl}`);
}
return reply.redirect(`${this.basePath}${this.APP_ROOT}/login?nextUrl=${nextUrl}`);
}
setupRoutes() {
require('./routes')(this.pluginRoot, this.server, this.kbnServer, this.APP_ROOT, this.API_ROOT);
}
}

View File

@ -0,0 +1,238 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import Boom from 'boom';
import Joi from 'joi';
import { isEmpty } from 'lodash';
import MissingTenantError from "../../errors/missing_tenant_error";
import MissingRoleError from "../../errors/missing_role_error";
import {parseNextUrl} from "../../parseNextUrl";
module.exports = function (pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
const AuthenticationError = pluginRoot('lib/auth/errors/authentication_error');
const loginApp = server.getHiddenUiAppById('security-login');
const config = server.config();
const customErrorApp = server.getHiddenUiAppById('security-customerror');
/**
* The login page.
*/
server.route({
method: 'GET',
path: `${APP_ROOT}/login`,
handler: {
async: async(request, reply) => {
try {
const basePath = config.get('server.basePath');
// Check if we have alternative login headers
const alternativeHeaders = config.get('opendistro_security.basicauth.alternative_login.headers');
if (alternativeHeaders && alternativeHeaders.length) {
let requestHeaders = Object.keys(request.headers).map(header => header.toLowerCase());
let foundHeaders = alternativeHeaders.filter(header => requestHeaders.indexOf(header.toLowerCase()) > -1);
if (foundHeaders.length) {
let {session} = await request.auth.securitySessionStorage.authenticateWithHeaders(request.headers);
let nextUrl = null;
if (request.url && request.url.query && request.url.query.nextUrl) {
nextUrl = parseNextUrl(request.url.query.nextUrl, basePath);
}
if (nextUrl) {
nextUrl = parseNextUrl(nextUrl, basePath);
return reply.redirect(nextUrl);
}
return reply.redirect(basePath + '/app/kibana');
}
}
} catch (error) {
if (error instanceof MissingRoleError) {
return reply.redirect(basePath + '/customerror?type=missingRole');
} else if (error instanceof MissingTenantError) {
return reply.redirect(basePath + '/customerror?type=missingTenant');
}
// Let normal authentication errors through(?) and just go to the regular login page?
}
return reply.renderAppWithDefaultConfig(loginApp);
}
},
config: {
auth: false
}
});
server.route({
method: 'POST',
path: `${API_ROOT}/auth/login`,
handler: {
async: async (request, reply) => {
try {
// In order to prevent direct access for certain usernames (e.g. service users like
// kibanaserver, logstash etc.) we can add them to basicauth.forbidden_usernames.
// If the username in the payload matches an item in the forbidden array, we throw an AuthenticationError
const basicAuthConfig = server.config().get('opendistro_security.basicauth');
if (basicAuthConfig.forbidden_usernames && basicAuthConfig.forbidden_usernames.length) {
if (request.payload && request.payload.username && basicAuthConfig.forbidden_usernames.indexOf(request.payload.username) > -1) {
throw new AuthenticationError('Invalid username or password');
}
}
const authHeaderValue = new Buffer(`${request.payload.username}:${request.payload.password}`).toString('base64');
let {user} = await request.auth.securitySessionStorage.authenticate({
authHeaderValue: 'Basic ' + authHeaderValue
});
// handle tenants if MT is enabled
if(server.config().get("opendistro_security.multitenancy.enabled")) {
// get the preferred tenant of the user
let globalTenantEnabled = server.config().get("opendistro_security.multitenancy.tenants.enable_global");
let privateTenantEnabled = server.config().get("opendistro_security.multitenancy.tenants.enable_private");
let preferredTenants = server.config().get("opendistro_security.multitenancy.tenants.preferred");
let finalTenant = server.plugins.opendistro_security.getSecurityBackend().getTenantByPreference(request, user.username, user.tenants, preferredTenants, globalTenantEnabled, privateTenantEnabled);
request.auth.securitySessionStorage.putStorage('tenant', {
selected: finalTenant
});
return reply({
username: user.username,
tenants: user.tenants,
roles: user.roles,
backendroles: user.backendroles,
selectedTenant: user.selectedTenant,
});
} else {
// no MT, nothing more to do
return reply({
username: user.username,
tenants: user.tenants
});
}
} catch (error) {
if (error instanceof AuthenticationError) {
return reply(Boom.unauthorized(error.message));
} else if (error instanceof MissingTenantError) {
return reply(Boom.notFound(error.message));
} else if (error instanceof MissingRoleError) {
return reply(Boom.notFound(error.message));
} else {
return reply(Boom.badImplementation(error.message));
}
}
}
},
config: {
validate: {
payload: {
username: Joi.string().required(),
password: Joi.string().required()
}
},
auth: false
}
});
server.route({
method: 'POST',
path: `${API_ROOT}/auth/logout`,
handler: (request, reply) => {
request.auth.securitySessionStorage.clear();
reply({});
},
config: {
auth: false
}
});
server.route({
method: 'GET',
path: `${APP_ROOT}/auth/anonymous`,
handler: {
async: async (request, reply) => {
if (server.config().get('opendistro_security.auth.anonymous_auth_enabled')) {
const basePath = server.config().get('server.basePath');
try {
let {session} = await request.auth.securitySessionStorage.authenticate({}, {isAnonymousAuth: true});
let nextUrl = null;
if (request.url && request.url.query && request.url.query.nextUrl) {
nextUrl = parseNextUrl(request.url.query.nextUrl, basePath);
}
if (nextUrl) {
nextUrl = parseNextUrl(nextUrl, basePath);
return reply.redirect(nextUrl);
}
return reply.redirect(basePath + '/app/kibana');
} catch (error) {
if (error instanceof MissingRoleError) {
return reply.redirect(basePath + '/customerror?type=missingRole');
} else if (error instanceof MissingTenantError) {
return reply.redirect(basePath + '/customerror?type=missingTenant');
} else {
return reply.redirect(basePath + '/customerror?type=anonymousAuthError');
}
}
} else {
return reply.redirect(`${APP_ROOT}/login`);
}
}
},
config: {
auth: false
}
});
/**
* The error page.
*/
server.route({
method: 'GET',
path: `${APP_ROOT}/customerror`,
handler(request, reply) {
return reply.renderAppWithDefaultConfig(customErrorApp);
},
config: {
auth: false
}
});
}; //end module

185
lib/auth/types/jwt/Jwt.js Normal file
View File

@ -0,0 +1,185 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import AuthType from "../AuthType";
import MissingTenantError from "../../errors/missing_tenant_error";
import SessionExpiredError from "../../errors/session_expired_error";
import {parse, format} from 'url';
import MissingRoleError from "../../errors/missing_role_error";
export default class Jwt extends AuthType {
constructor(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
super(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT);
/**
* The authType is saved in the auth cookie for later reference
* @type {string}
*/
this.type = 'jwt';
try {
this.authHeaderName = this.config.get('opendistro_security.jwt.header').toLowerCase();
} catch(error) {
this.kbnServer.status.yellow('No authorization header name defined for JWT, using "authorization"');
this.authHeaderName = 'authorization'
}
}
/**
* Detect authorization header value, either as an http header or as a query parameter
* @param request
* @param sessionCredentials
* @returns {*}
*/
detectAuthHeaderCredentials(request, sessionCredentials = null) {
let authHeaderValue = null;
const urlparamname = this.config.get('opendistro_security.jwt.url_param').toLowerCase();
// Go through all given query parameters and make them lowercase
// to avoid confusion when using uppercase or perhaps mixed caps
let lowerCaseQueryParameters = {};
Object.keys(request.query).forEach((query) => {
lowerCaseQueryParameters[query.toLowerCase()] = request.query[query];
});
let jwtAuthParam = lowerCaseQueryParameters[urlparamname] || null;
// The token may be passed via a query parameter
if (jwtAuthParam != null) {
authHeaderValue = 'Bearer ' + jwtAuthParam;
request.headers[this.authHeaderName] = authHeaderValue;
} else if (request.headers[this.authHeaderName]) {
try {
authHeaderValue = request.headers[this.authHeaderName];
} catch (error) {
console.log('Something went wrong when getting the JWT bearer from the header', request.headers)
}
}
// If we have sessionCredentials AND auth headers we need to check if they are the same.
if (authHeaderValue !== null && sessionCredentials !== null && sessionCredentials.authHeaderValue === authHeaderValue) {
// The auth header credentials are the same as those in the session,
// no need to return new credentials so we're just nulling the token here
return null
}
if (authHeaderValue !== null) {
return {
authHeaderValue: authHeaderValue
}
}
return authHeaderValue;
}
async authenticate(credentials) {
// A "login" can happen when we have a token (as header or as URL parameter but no session,
// or when we have an existing session, but the passed token does not match what's in the session.
try {
let user = await this.server.plugins.opendistro_security.getSecurityBackend().authenticateWithHeader(this.authHeaderName, credentials.authHeaderValue);
let tokenPayload = {};
try {
tokenPayload = JSON.parse(Buffer.from(credentials.authHeaderValue.split('.')[1], 'base64').toString());
} catch (error) {
// Something went wrong while parsing the payload, but the user was authenticated correctly.
}
let session = {
username: user.username,
credentials: credentials,
authType: this.type
};
if (tokenPayload.exp) {
// The token's exp value trumps the config setting
this.sessionKeepAlive = false;
session.exp = parseInt(tokenPayload.exp, 10);
} else if(this.sessionTTL) {
session.expiryTime = Date.now() + this.sessionTTL
}
return {
session,
user
};
} catch (error) {
throw error;
}
}
onUnAuthenticated(request, reply, error) {
if (error instanceof MissingTenantError) {
return reply.redirect(this.basePath + '/customerror?type=missingTenant');
} else if (error instanceof MissingRoleError) {
return reply.redirect(this.basePath + '/customerror?type=missingRole');
} else {
// The customer may use a login endpoint, to which we can redirect
// if the user isn't authenticated.
let loginEndpoint = this.config.get('opendistro_security.jwt.login_endpoint');
if (loginEndpoint) {
try {
// Parse the login endpoint so that we can append our nextUrl
// if the customer has defined query parameters in the endpoint
let loginEndpointURLObject = parse(loginEndpoint, true);
// Make sure we don't overwrite an existing "nextUrl" parameter,
// just in case the customer is using that name for something else
if (typeof loginEndpointURLObject.query['nextUrl'] === 'undefined') {
const nextUrl = encodeURIComponent(request.url.path);
// Delete the search parameter - otherwise format() will use its value instead of the .query property
delete loginEndpointURLObject.search;
loginEndpointURLObject.query['nextUrl'] = nextUrl;
}
// Format the parsed endpoint object into a URL and redirect
return reply.redirect(format(loginEndpointURLObject));
} catch(error) {
this.server.log(['error', 'security'], 'An error occured while parsing the opendistro_security.jwt.login_endpoint value');
return reply.redirect(this.basePath + '/customerror?type=authError');
}
} else if (error instanceof SessionExpiredError) {
return reply.redirect(this.basePath + '/customerror?type=sessionExpired');
} else {
return reply.redirect(this.basePath + '/customerror?type=authError');
}
}
}
setupRoutes() {
require('./routes')(this.pluginRoot, this.server, this.kbnServer, this.APP_ROOT, this.API_ROOT);
}
}

View File

@ -0,0 +1,76 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
module.exports = function (pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
const customErrorApp = server.getHiddenUiAppById('security-customerror');
/**
* After a logout we are redirected to a login page
*/
server.route({
method: 'GET',
path: `${APP_ROOT}/login`,
handler(request, reply) {
return reply.renderAppWithDefaultConfig(customErrorApp);
},
config: {
auth: false
}
});
/**
* The error page.
*/
server.route({
method: 'GET',
path: `${APP_ROOT}/customerror`,
handler(request, reply) {
return reply.renderAppWithDefaultConfig(customErrorApp);
},
config: {
auth: false
}
});
server.route({
method: 'POST',
path: `${API_ROOT}/auth/logout`,
handler: (request, reply) => {
request.auth.securitySessionStorage.clear();
reply({});
},
config: {
auth: false
}
});
}; //end module

View File

@ -0,0 +1,150 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import AuthType from "../AuthType";
import MissingTenantError from "../../errors/missing_tenant_error";
import AuthenticationError from "../../errors/authentication_error";
import MissingRoleError from "../../errors/missing_role_error";
const Wreck = require('wreck');
const https = require('https');
const fs = require('fs');
export default class OpenId extends AuthType {
constructor(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
super(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT);
/**
* The authType is saved in the auth cookie for later reference
* @type {string}
*/
this.type = 'openid';
// support for self signed certificates: root ca and verify hostname
const options = {};
if (this.config.get('opendistro_security.openid.root_ca')) {
options.ca = [ fs.readFileSync(this.config.get('opendistro_security.openid.root_ca')) ]
}
if (this.config.get('opendistro_security.openid.verify_hostnames') == false) {
// do not check identity
options.checkServerIdentity = function(host, cert) {}
}
if (options.ca || options.checkServerIdentity) {
Wreck.agents.https = new https.Agent(options);
}
try {
this.authHeaderName = this.config.get('opendistro_security.openid.header').toLowerCase();
} catch(error) {
this.kbnServer.status.yellow('No authorization header name defined for OpenId, using "authorization"');
this.authHeaderName = 'authorization'
}
}
async authenticate(credentials) {
// A "login" can happen when we have a token (as header or as URL parameter but no session,
// or when we have an existing session, but the passed token does not match what's in the session.
try {
let user = await this.server.plugins.opendistro_security.getSecurityBackend().authenticateWithHeader(this.authHeaderName, credentials.authHeaderValue);
let tokenPayload = {};
try {
tokenPayload = JSON.parse(Buffer.from(credentials.authHeaderValue.split('.')[1], 'base64').toString());
} catch (error) {
// Something went wrong while parsing the payload, but the user was authenticated correctly.
}
let session = {
username: user.username,
credentials: credentials,
authType: this.type
};
if (tokenPayload.exp) {
// The token's exp value trumps the config setting
this.sessionKeepAlive = false;
session.exp = parseInt(tokenPayload.exp, 10);
} else if(this.sessionTTL) {
session.expiryTime = Date.now() + this.sessionTTL
}
return {
session,
user
};
} catch (error) {
throw error
}
}
onUnAuthenticated(request, reply, error) {
// If we don't have any tenant we need to show the custom error page
if (error instanceof MissingTenantError) {
return reply.redirect(this.basePath + '/customerror?type=missingTenant')
} else if (error instanceof MissingRoleError) {
return reply.redirect(this.basePath + '/customerror?type=missingRole')
} else if (error instanceof AuthenticationError) {
return reply.redirect(this.basePath + '/customerror?type=authError')
}
const nextUrl = encodeURIComponent(request.url.path);
return reply.redirect(`${this.basePath}/auth/openid/login?nextUrl=${nextUrl}`);
}
async setupRoutes() {
Wreck.get(this.config.get('opendistro_security.openid.connect_url'), (err, response, payload) => {
if (err ||
response.statusCode < 200 ||
response.statusCode > 299) {
this.server.log(["error", "openid"], err);
throw new Error('Failed when trying to obtain the endpoints from your IdP');
}
const parsedPayload = JSON.parse(payload.toString());
let endPoints = {
authorization_endpoint: parsedPayload.authorization_endpoint,
token_endpoint: parsedPayload.token_endpoint,
end_session_endpoint: parsedPayload.end_session_endpoint || null
};
require('./routes')(this.pluginRoot, this.server, this.kbnServer, this.APP_ROOT, this.API_ROOT, endPoints);
});
}
}

View File

@ -0,0 +1,211 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import Boom from 'boom';
import {parseNextUrl} from '../../parseNextUrl'
import MissingTenantError from "../../errors/missing_tenant_error";
module.exports = function (pluginRoot, server, kbnServer, APP_ROOT, API_ROOT, openIdEndPoints) {
const AuthenticationError = pluginRoot('lib/auth/errors/authentication_error');
const config = server.config();
const basePath = config.get('server.basePath');
const customErrorApp = server.getHiddenUiAppById('security-customerror');
const routesPath = '/auth/openid/';
// OpenId config
const clientId = config.get('opendistro_security.openid.client_id');
const clientSecret = config.get('opendistro_security.openid.client_secret');
// Scope must include "openid"
// Other available scopes as per the spec: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
let scope = config.get('opendistro_security.openid.scope').split(' ');
if (scope.indexOf('openid') === -1) {
scope.push('openid');
}
/**
* The redirect uri can't always be resolved automatically.
* Instead, we have the opendistro_security.openid.base_redirect_uri config option.
* @returns {*}
*/
function getBaseRedirectUrl() {
const configuredBaseRedirectUrl = config.get('opendistro_security.openid.base_redirect_url');
if (configuredBaseRedirectUrl) {
return (configuredBaseRedirectUrl.endsWith('/')) ? configuredBaseRedirectUrl.slice(0, -1) : configuredBaseRedirectUrl;
}
// Config option not used, try to get the correct protocol and host
let host = config.get('server.host');
let port = config.get('server.port');
if (port) {
host = host + ':' + port;
}
return `${server.info.protocol}://${host}`;
}
/**
* Error handler for the cases where we can't catch errors while obtaining the token.
* Mainly happens when Wreck within Bell
*/
server.ext('onPreResponse', function(request, reply) {
// Make sure we only handle errors for the login route
if (request.response.isBoom && request.path.indexOf(`${APP_ROOT}${routesPath}login`) > -1 && request.response.output.statusCode === 500) {
return reply.redirect(basePath + '/customerror?type=authError');
}
reply.continue();
});
// Register bell with the server
server.register(require('bell'), function (err) {
let baseRedirectUrl = getBaseRedirectUrl();
let location = `${baseRedirectUrl}${basePath}`;
server.auth.strategy('customOAuth', 'bell', {
provider: {
auth: openIdEndPoints.authorization_endpoint,
token: openIdEndPoints.token_endpoint,
scope: scope,
protocol: 'oauth2',
useParamsAuth: true,
},
skipProfile: true,
location: encodeURI(location),
password: config.get('opendistro_security.cookie.password'),
clientId: clientId,
clientSecret: clientSecret,
isSecure: config.get('opendistro_security.cookie.secure'),
});
/**
* The login page.
*/
server.route({
method: ['GET', 'POST'],
path: `${APP_ROOT}${routesPath}login`,
config: {
auth: 'customOAuth'
},
handler: {
async: async (request, reply) => {
if (!request.auth.isAuthenticated) {
return reply.redirect(basePath + '/customerror?type=authError');
}
let credentials = request.auth.credentials;
let nextUrl = (credentials.query && credentials.query.nextUrl) ? credentials.query.nextUrl : null;
try {
// Bell gives us the access token to identify with here,
// but we want the id_token returned from the IDP
let {user} = await request.auth.securitySessionStorage.authenticate({
authHeaderValue: 'Bearer ' + request.auth.artifacts['id_token']
});
if (nextUrl) {
nextUrl = parseNextUrl(nextUrl, basePath);
return reply.redirect(nextUrl);
}
return reply.redirect(basePath + '/app/kibana');
}
catch (error) {
if (error instanceof AuthenticationError) {
return reply.redirect(basePath + '/customerror?type=authError');
} else if (error instanceof MissingTenantError) {
return reply.redirect(basePath + '/customerror?type=missingTenant');
} else {
return reply.redirect(basePath + '/customerror?type=authError');
}
}
}
}
});
});
/**
* The error page.
*/
server.route({
method: 'GET',
path: `${APP_ROOT}/customerror`,
handler(request, reply) {
return reply.renderAppWithDefaultConfig(customErrorApp);
},
config: {
auth: false
}
});
/**
* Clears the session and logs the user out from the IdP (if we have an endpoint available)
* @url http://openid.net/specs/openid-connect-session-1_0.html#RPLogout
*/
server.route({
method: 'POST',
path: `${API_ROOT}/auth/logout`,
handler: (request, reply) => {
request.auth.securitySessionStorage.clear();
// Build the redirect uri needed by the provider
let baseRedirectUrl = getBaseRedirectUrl();
// Unfortunately, it seems like the cookie plugin isn't available yet,
// which means that we can't use the new plugin
const cookieName = config.get('opendistro_security.cookie.name');
// Get the session credentials and remove "Bearer " from the value
const token = request.state[cookieName].credentials.authHeaderValue.split(' ')[1];
let requestQueryParameters = `?post_logout_redirect_uri=${baseRedirectUrl}${basePath}/app/kibana`;
// If we don't have an "end_session_endpoint" in the .well-known list,
// we may have a custom logout_url defined in the config.
// The custom url trumps the .well-known endpoint if both are available.
let customLogoutUrl = config.get('opendistro_security.openid.logout_url');
let endSessionUrl = null
if (customLogoutUrl) {
// Pass the post_logout_uri just in case, but not the token
endSessionUrl = customLogoutUrl + requestQueryParameters;
} else if (openIdEndPoints.end_session_endpoint) {
endSessionUrl = openIdEndPoints.end_session_endpoint + requestQueryParameters + '&id_token_hint=' + token;
}
reply({redirectURL: endSessionUrl});
},
config: {
auth: false
}
});
}; //end module

View File

@ -0,0 +1,176 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import AuthType from "../AuthType";
import MissingTenantError from "../../errors/missing_tenant_error";
import SessionExpiredError from "../../errors/session_expired_error";
import {parse, format} from 'url';
import MissingRoleError from "../../errors/missing_role_error";
import {parseLoginEndpoint} from "./parse_login_endpoint";
export default class ProxyCache extends AuthType {
constructor(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
super(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT);
/**
* The authType is saved in the auth cookie for later reference
* @type {string}
*/
this.type = 'proxycache';
/**
* The header that identifies the user
*/
this.userHeaderName = this.config.get('opendistro_security.proxycache.user_header').toLowerCase();
/**
* The header that identifies the user's role(s). Optional.
*/
this.rolesHeaderName = this.config.get('opendistro_security.proxycache.roles_header').toLowerCase();
}
/**
* Detect authorization header value, either as an http header or as a query parameter
* @param request
* @param sessionCredentials
* @returns {*}
*/
detectAuthHeaderCredentials(request, sessionCredentials = null) {
// The point of ProxyCache is that we only have headers on the first request.
// In other words, if we already have a session, we don't need to check the headers.
if (sessionCredentials !== null) {
return null;
}
if (request.headers[this.userHeaderName]) {
const authHeaderValues = {
[this.userHeaderName]: request.headers[this.userHeaderName],
'x-forwarded-for': request.headers['x-forwarded-for']
};
// The roles header is optional
if (request.headers[this.rolesHeaderName]) {
authHeaderValues[this.rolesHeaderName] = request.headers[this.rolesHeaderName];
}
return authHeaderValues;
} else if (request.headers[this.authHeaderName]) {
return {
[this.authHeaderName]: request.headers[this.authHeaderName]
}
}
// We still need to support basic auth for Curl etc.
return null;
}
/**
* Returns the auth header(s) needed for the Security backend
* @param session
* @returns {*}
*/
getAuthHeader(session) {
if (! session.credentials) {
return false;
}
if (session.credentials[this.userHeaderName]) {
return {
[this.userHeaderName]: session.credentials[this.userHeaderName],
[this.rolesHeaderName]: session.credentials[this.rolesHeaderName]
}
} else if (session.credentials[this.authHeaderName]) {
return {
[this.authHeaderName]: session.credentials[this.authHeaderName]
}
}
return false;
}
async authenticate(credentialHeaders) {
try {
let user = await this.server.plugins.opendistro_security.getSecurityBackend().authenticateWithHeaders(credentialHeaders, credentialHeaders);
let session = {
username: user.username,
credentials: credentialHeaders,
authType: this.type
};
if(this.sessionTTL) {
session.expiryTime = Date.now() + this.sessionTTL
}
return {
session,
user
};
} catch (error) {
throw error;
}
}
onUnAuthenticated(request, reply, error) {
if (error instanceof MissingTenantError) {
return reply.redirect(this.basePath + '/customerror?type=missingTenant');
} else if (error instanceof MissingRoleError) {
return reply.redirect(this.basePath + '/customerror?type=missingRole');
} else {
// The customer may use a login endpoint, to which we can redirect
// if the user isn't authenticated.
let loginEndpoint = this.config.get('opendistro_security.proxycache.login_endpoint');
if (loginEndpoint) {
try {
const redirectUrl = parseLoginEndpoint(loginEndpoint, request);
return reply.redirect(redirectUrl);
} catch(error) {
this.server.log(['error', 'security'], 'An error occured while parsing the opendistro_security.proxycache.login_endpoint value');
return reply.redirect(this.basePath + '/customerror?type=proxycacheAuthError');
}
} else if (error instanceof SessionExpiredError) {
return reply.redirect(this.basePath + '/customerror?type=sessionExpired');
} else {
return reply.redirect(this.basePath + '/customerror?type=proxycacheAuthError');
}
}
}
setupRoutes() {
require('./routes')(this.pluginRoot, this.server, this.kbnServer, this.APP_ROOT, this.API_ROOT);
}
}

View File

@ -0,0 +1,55 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import {parse, format} from 'url';
/**
*
* @param loginEndpoint
* @param request - Optional, only needed if we should append a "nextUrl" query parameter
* @returns {*}
*/
export function parseLoginEndpoint(loginEndpoint, request = null) {
// Parse the login endpoint so that we can append our nextUrl
// if the customer has defined query parameters in the endpoint
let loginEndpointURLObject = parse(loginEndpoint, true);
// Make sure we don't overwrite an existing "nextUrl" parameter,
// just in case the customer is using that name for something else
if (typeof loginEndpointURLObject.query['nextUrl'] === 'undefined' && request) {
const nextUrl = encodeURIComponent(request.url.path);
// Delete the search parameter - otherwise format() will use its value instead of the .query property
delete loginEndpointURLObject.search;
loginEndpointURLObject.query['nextUrl'] = nextUrl;
}
// Format the parsed endpoint object into a URL and return
return format(loginEndpointURLObject);
}

View File

@ -0,0 +1,90 @@
import {parseLoginEndpoint} from "./parse_login_endpoint";
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
module.exports = function (pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
const customErrorApp = server.getHiddenUiAppById('security-customerror');
/**
* After a logout we are redirected to a login page
*/
server.route({
method: 'GET',
path: `${APP_ROOT}/login`,
handler(request, reply) {
// The customer may use a login endpoint, to which we can redirect
// if the user isn't authenticated.
let loginEndpoint = server.config().get('opendistro_security.proxycache.login_endpoint');
if (loginEndpoint) {
try {
const redirectUrl = parseLoginEndpoint(loginEndpoint);
return reply.redirect(redirectUrl);
} catch(error) {
this.server.log(['error', 'security'], 'An error occured while parsing the opendistro_security.proxycache.login_endpoint value');
}
} else {
return reply.renderAppWithDefaultConfig(customErrorApp);
}
},
config: {
auth: false
}
});
/**
* The error page.
*/
server.route({
method: 'GET',
path: `${APP_ROOT}/customerror`,
handler(request, reply) {
return reply.renderAppWithDefaultConfig(customErrorApp);
},
config: {
auth: false
}
});
server.route({
method: 'POST',
path: `${API_ROOT}/auth/logout`,
handler: (request, reply) => {
request.auth.securitySessionStorage.clear();
reply({});
},
config: {
auth: false
}
});
}; //end module

107
lib/auth/types/saml/Saml.js Normal file
View File

@ -0,0 +1,107 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import AuthType from "../AuthType";
import MissingTenantError from "../../errors/missing_tenant_error";
import AuthenticationError from "../../errors/authentication_error";
import MissingRoleError from "../../errors/missing_role_error";
export default class Saml extends AuthType {
constructor(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
super(pluginRoot, server, kbnServer, APP_ROOT, API_ROOT);
/**
* The authType is saved in the auth cookie for later reference
* @type {string}
*/
this.type = 'saml';
}
async authenticate(credentials) {
// A "login" can happen when we have a token (as header or as URL parameter but no session,
// or when we have an existing session, but the passed token does not match what's in the session.
try {
let user = await this.server.plugins.opendistro_security.getSecurityBackend().authenticateWithHeader(this.authHeaderName, credentials.authHeaderValue);
let tokenPayload = {};
try {
tokenPayload = JSON.parse(Buffer.from(credentials.authHeaderValue.split('.')[1], 'base64').toString());
} catch (error) {
// Something went wrong while parsing the payload, but the user was authenticated correctly.
}
let session = {
username: user.username,
credentials: credentials,
authType: this.type
};
if (tokenPayload.exp) {
// The token's exp value trumps the config setting
this.sessionKeepAlive = false;
session.exp = parseInt(tokenPayload.exp, 10);
} else if(this.sessionTTL) {
session.expiryTime = Date.now() + this.sessionTTL
}
return {
session,
user
};
} catch (error) {
throw error
}
}
onUnAuthenticated(request, reply, error) {
// If we don't have any tenant we need to show the custom error page
if (error instanceof MissingTenantError) {
return reply.redirect(this.basePath + '/customerror?type=missingTenant')
} else if (error instanceof MissingRoleError) {
return reply.redirect(this.basePath + '/customerror?type=missingRole')
} else if (error instanceof AuthenticationError) {
return reply.redirect(this.basePath + '/customerror?type=samlAuthError')
}
const nextUrl = encodeURIComponent(request.url.path);
return reply.redirect(`${this.basePath}/auth/saml/login?nextUrl=${nextUrl}`);
}
setupRoutes() {
require('./routes')(this.pluginRoot, this.server, this.kbnServer, this.APP_ROOT, this.API_ROOT);
}
}

View File

@ -0,0 +1,234 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import Boom from 'boom';
import {parseNextUrl} from '../../parseNextUrl'
import MissingTenantError from "../../errors/missing_tenant_error";
import AuthenticationError from "../../errors/authentication_error";
module.exports = function (pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
const AuthenticationError = pluginRoot('lib/auth/errors/authentication_error');
const config = server.config();
const basePath = config.get('server.basePath');
const customErrorApp = server.getHiddenUiAppById('security-customerror');
const routesPath = '/auth/saml/';
/**
* The login page.
*/
server.route({
method: 'GET',
path: `${APP_ROOT}${routesPath}login`,
config: {
auth: false
},
handler: {
async: async (request, reply) => {
if (request.auth.isAuthenticated) {
return reply.redirect(basePath + '/app/kibana');
}
let nextUrl = null;
if (request.url && request.url.query && request.url.query.nextUrl) {
nextUrl = parseNextUrl(request.url.query.nextUrl, basePath);
}
try {
// Grab the request for SAML
server.plugins.opendistro_security.getSecurityBackend().getSamlHeader()
.then((samlHeader) => {
request.auth.securitySessionStorage.putStorage('temp-saml', {
requestId: samlHeader.requestId,
nextUrl: nextUrl
});
return reply.redirect(samlHeader.location);
})
.catch(() => {
return reply.redirect(basePath + '/customerror?type=samlConfigError');
});
} catch (error) {
return reply.redirect(basePath + '/customerror?type=samlConfigError');
}
}
}
});
/**
* The page that the IdP redirects to after a successful SP-initiated login
*/
server.route({
method: 'POST',
path: `${APP_ROOT}/_opendistro/_security/saml/acs`,
config: {
auth: false
},
handler: {
async: async (request, reply) => {
let storedRequestInfo = request.auth.securitySessionStorage.getStorage('temp-saml', {});
request.auth.securitySessionStorage.clearStorage('temp-saml');
if (! storedRequestInfo.requestId) {
return reply.redirect(basePath + '/customerror?type=samlAuthError');
}
try {
let credentials = await server.plugins.opendistro_security.getSecurityBackend().authtoken(storedRequestInfo.requestId || null, request.payload.SAMLResponse);
let {user} = await request.auth.securitySessionStorage.authenticate({
authHeaderValue: credentials.authorization
});
let nextUrl = storedRequestInfo.nextUrl;
if (nextUrl) {
nextUrl = parseNextUrl(nextUrl, basePath);
return reply.redirect(nextUrl);
}
return reply.redirect(basePath + '/app/kibana');
} catch (error) {
if (error instanceof AuthenticationError) {
return reply.redirect(basePath + '/customerror?type=samlAuthError');
} else if (error instanceof MissingTenantError) {
return reply.redirect(basePath + '/customerror?type=missingTenant');
} else {
return reply.redirect(basePath + '/customerror?type=samlAuthError');
}
}
}
}
});
/**
* The page that the IdP redirects to after a successful IdP-initiated login
*/
server.route({
method: 'POST',
path: `${APP_ROOT}/_opendistro/_security/saml/acs/idpinitiated`,
config: {
auth: false
},
handler: {
async: async (request, reply) => {
try {
const acsEndpoint = `${APP_ROOT}/_opendistro/_security/saml/acs/idpinitiated`;
let credentials = await server.plugins.opendistro_security.getSecurityBackend().authtoken(null, request.payload.SAMLResponse, acsEndpoint);
let {user} = await request.auth.securitySessionStorage.authenticate({
authHeaderValue: credentials.authorization
});
return reply.redirect(basePath + '/app/kibana');
} catch (error) {
if (error instanceof AuthenticationError) {
return reply.redirect(basePath + '/customerror?type=samlAuthError');
} else if (error instanceof MissingTenantError) {
return reply.redirect(basePath + '/customerror?type=missingTenant');
} else {
return reply.redirect(basePath + '/customerror?type=samlAuthError');
}
}
}
}
});
/**
* The custom error page.
*/
server.route({
method: ['GET', 'POST'],
path: `${APP_ROOT}/opendistro_security/saml/logout`,
handler(request, reply) {
return reply.redirect(`${APP_ROOT}/customerror?type=samlLogoutSuccess`);
},
config: {
auth: false
}
});
/**
* The custom error page.
*/
server.route({
method: 'GET',
path: `${APP_ROOT}/customerror`,
handler(request, reply) {
return reply.renderAppWithDefaultConfig(customErrorApp);
},
config: {
auth: false
}
});
/**
* Logout
*/
server.route({
method: 'POST',
path: `${API_ROOT}/auth/logout`,
handler: {
async: async(request, reply) => {
const cookieName = config.get('opendistro_security.cookie.name');
let authInfo = null;
try {
let authHeader = {
[request.auth.securitySessionStorage.getAuthHeaderName()]: request.state[cookieName].credentials.authHeaderValue
};
authInfo = await server.plugins.opendistro_security.getSecurityBackend().authinfo(authHeader);
} catch(error) {
// Not much we can do here, so we'll just fall back to the login page if we don't get an sso_logout_url
}
request.auth.securitySessionStorage.clear();
const redirectURL = (authInfo && authInfo.sso_logout_url) ? authInfo.sso_logout_url : `${APP_ROOT}/customerror?type=samlLogoutSuccess`;
reply({redirectURL});
}
},
config: {
auth: false
}
});
}; //end module

96
lib/auth/user.js Normal file
View File

@ -0,0 +1,96 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
/**
* Represents a Security user
*/
export default class User {
/**
* @property {string} username - The username.
*/
get username() {
return this._username;
}
/**
* @property {Array} roles - The user roles.
*/
get roles() {
return this._roles;
}
/**
* @property {Array} roles - The users unmapped backend roles.
*/
get backendroles() {
return this._backendroles;
}
/**
* @property {Array} tenants - The user tenants.
*/
get tenants() {
return this._tenants;
}
/**
* @property {Array} tenants - The user tenants.
*/
get selectedTenant() {
return this._selectedTenant;
}
/**
* @property {object} credentials - The credentials that were used to authenticate the user.
*/
get credentials() {
return this._credentials;
}
/**
* @property {object} proxyCredentials - User credentials to be used in requests to Elasticsearch performed by either the transport client
* or the query engine.
*/
get proxyCredentials() {
return this._proxyCredentials;
}
constructor(username, credentials, proxyCredentials, roles, backendroles, tenants, selectedTenant) {
this._username = username;
this._credentials = credentials;
this._proxyCredentials = proxyCredentials;
this._roles = roles;
this._selectedTenant = selectedTenant;
this._backendroles = backendroles;
this._tenants = tenants;
}
}

View File

@ -0,0 +1,17 @@
/**
* Thrown when an existing object with the same identifier already exists.
*/
export default class ConflictError {
/**
* Creates a new ConflictError.
*
* @param {string} message - The error message.
* @param {Error} inner - An optional error that caused the ConflictError.
*/
constructor(message, inner) {
this.name = 'ConflictError';
this.message = message;
this.inner = inner;
this.stack = new Error().stack;
}
}

View File

@ -0,0 +1,17 @@
/**
* Thrown when an object is not found.
*/
export default class NotFoundError {
/**
* Creates a new NotFoundError.
*
* @param {string} message - The error message.
* @param {Error} inner - An optional error that caused the NotFoundError.
*/
constructor(message, inner) {
this.name = 'NotFoundError';
this.message = message;
this.inner = inner;
this.stack = new Error().stack;
}
}

View File

@ -0,0 +1,41 @@
import { get } from 'lodash';
import Boom from 'boom';
import AuthenticationError from '../../auth/errors/authentication_error';
/**
* Wraps an Elasticsearch client error into a backend error.
*
* @param {Error} error - An Elasticsearch client error.
*/
export default function wrapElasticsearchError(error) {
let statusCode = error.statusCode;
if (error.status) {
statusCode = error.status;
}
if (!statusCode) {
statusCode = 500;
}
let message = get(error, 'body.message');
if (!message) {
message = error.message;
}
const wwwAuthHeader = get(error, 'body.error.header[WWW-Authenticate]');
if (wwwAuthHeader) {
const boomError = Boom.boomify(error, { statusCode: statusCode, message: message });
boomError.output.headers['WWW-Authenticate'] = wwwAuthHeader || 'Basic realm="Authorization Required"';
return boomError;
}
if (statusCode == 401) {
return new AuthenticationError();
}
return Boom.boomify(error, { statusCode: statusCode, message: message });
}

View File

@ -0,0 +1,366 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import _ from 'lodash';
import { get } from 'lodash';
import filterAuthHeaders from '../auth/filter_auth_headers';
import SecurityPlugin from './opendistro_security_plugin';
import AuthenticationError from '../auth/errors/authentication_error';
import wrapElasticsearchError from './errors/wrap_elasticsearch_error';
import User from '../auth/user';
/**
* The Security backend.
*/
export default class SecurityBackend {
constructor(server) {
// client for authentication and authorization
const config = Object.assign({ plugins: [SecurityPlugin], auth: true }, server.config().get('elasticsearch'));
this._cluster = server.plugins.elasticsearch.createCluster('security', config
);
this._client = this._cluster._noAuthClient;
// the es config for later use
this._esconfig = server.config().get('elasticsearch');
}
async authenticate(credentials) {
const authHeader = new Buffer(`${credentials.username}:${credentials.password}`).toString('base64');
try {
const response = await this._client.opendistro_security.authinfo({
headers: {
authorization: `Basic ${authHeader}`
}
});
return new User(credentials.username, credentials, credentials, response.roles, response.backend_roles, response.tenants, response.user_requested_tenant);
} catch(error) {
if (error.status == 401) {
throw new AuthenticationError("Invalid username or password");
} else {
throw new Error(error.message);
}
}
}
async authenticateWithHeader(headerName, headerValue) {
try {
const credentials = {
headerName: headerName,
headerValue: headerValue
};
let headers = {};
// For anonymous auth, we wouldn't have any value here
if (headerValue) {
headers[headerName] = headerValue
}
const response = await this._client.opendistro_security.authinfo({
headers: headers
});
return new User(response.user_name, credentials, null, response.roles, response.backend_roles, response.tenants, response.user_requested_tenant);
} catch(error) {
if (error.status == 401) {
throw new AuthenticationError("Invalid username or password");
} else {
throw new Error(error.message);
}
}
}
/**
* A wrapper for authinfo() when we expect a response to be used for a cookie
* @param headers
* @param credentials
* @returns {Promise<User>}
*/
async authenticateWithHeaders(headers, credentials = {}) {
try {
const response = await this._client.opendistro_security.authinfo({
headers: headers
});
return new User(response.user_name, credentials, null, response.roles, response.backend_roles, response.tenants, response.user_requested_tenant);
} catch(error) {
if (error.status == 401) {
throw new AuthenticationError("Invalid username or password");
} else {
throw new Error(error.message);
}
}
}
buildSessionResponse(credentials, authInfoResponse) {
return new User(authInfoResponse.user_name, credentials, null, authInfoResponse.roles, authInfoResponse.backend_roles, authInfoResponse.tenants, authInfoResponse.user_requested_tenant);
}
async authinfo(headers) {
try {
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
const response = await this._client.opendistro_security.authinfo({
headers: authHeaders
});
return response
} catch(error) {
throw wrapElasticsearchError(error);
}
}
getSamlHeader() {
return this._client.opendistro_security.authinfo({})
.then(() => {})
.catch((error) => {
if (! error.wwwAuthenticateDirective) {
throw error;
}
try {
let locationRegExp = /location="(.*?)"/;
let requestIdRegExp = /requestId="(.*?)"/;
return {
location: locationRegExp.exec(error.wwwAuthenticateDirective)[1],
requestId: requestIdRegExp.exec(error.wwwAuthenticateDirective)[1]
}
} catch (error) {
throw new AuthenticationError();
}
});
}
/**
* Exchanges a SAMLResponse from the IdP against a token for internal use
* @param RequestId
* @param SAMLResponse
* @param acsEndpoint
* @returns {Promise<Promise<*>|*>}
*/
async authtoken(RequestId, SAMLResponse, acsEndpoint = null) {
const body = {
RequestId,
SAMLResponse,
acsEndpoint
};
return this._client.opendistro_security.authtoken({
body: body,
});
}
async multitenancyinfo(headers) {
try {
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
const response = await this._client.opendistro_security.multitenancyinfo({
headers: authHeaders
});
return response
} catch(error) {
throw wrapElasticsearchError(error);
}
}
async getTenantInfo(headers) {
try {
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
const response = await this._client.opendistro_security.tenantinfo({
headers: authHeaders
});
return response
} catch(error) {
throw wrapElasticsearchError(error);
}
}
/**
* @deprecated, use the sessionPlugin instead
* @param user
* @returns {Promise<{authorization: string}>}
*/
async getAuthHeaders(user) {
const credentials = user.credentials;
const authHeader = new Buffer(`${credentials.username}:${credentials.password}`).toString('base64');
return {
'authorization': `Basic ${authHeader}`
};
}
getAuthHeaders(username, password) {
const authHeader = new Buffer(`${username}:${password}`).toString('base64');
return {
'authorization': `Basic ${authHeader}`
};
}
getUser(username, password) {
var credentials = {"username": username, "password": password};
var user = new User(credentials.username, credentials, credentials, [], {});
return user;
}
getServerUser() {
return this.getUser(this._esconfig.username, this._esconfig.password);
}
getServerUserAuthHeader() {
return this.getAuthHeaders(this._esconfig.username, this._esconfig.password);
}
updateAndGetTenantPreferences(request, user, tenant) {
var prefs = request.state.security_preferences;
// no prefs cookie present
if (!prefs) {
var newPrefs = {};
newPrefs[user] = tenant;
return newPrefs;
}
prefs[user] = tenant;
return prefs;
}
getTenantByPreference(request, username, tenants, preferredTenants, globalEnabled, privateEnabled) {
// delete user from tenants first to check if we have a tenant to choose from at all
// keep original preferences untouched, we need the original values again
// http://stackoverflow.com/questions/728360/how-do-i-correctly-clone-a-javascript-object
var tenantsCopy = JSON.parse(JSON.stringify(tenants));
delete tenantsCopy[username];
// sanity check
if (!globalEnabled && !privateEnabled && _.isEmpty(tenantsCopy)) {
return null;
}
// get users preferred tenant
var prefs = request.state.security_preferences;
if (prefs) {
var preferredTenant = prefs[username];
// user has a preferred tenant, check if it is accessible
if (preferredTenant && tenants[preferredTenant] != undefined) {
return preferredTenant;
}
// special case: in tenants returned from SECURITY, the private tenant is
// the username of the logged in user, but the header value is __user__
if ((preferredTenant == "__user__" || preferredTenant == "private") && tenants[username] != undefined && privateEnabled) {
return "__user__";
}
if ((preferredTenant == "global" || preferredTenant === '' ) && globalEnabled) {
return "";
}
}
// no preference in cookie, or tenant no accessible anymore, evaluate preferredTenants from kibana config
if (preferredTenants && !_.isEmpty(preferredTenants)) {
for (var i = 0; i < preferredTenants.length; i++) {
var check = preferredTenants[i].toLowerCase();
if (globalEnabled && (check === 'global' || check === '__global__')) {
return '';
}
if (privateEnabled && (check === 'private' || check === '__user__') && tenants[username] != undefined) {
return '__user__';
}
if (tenants[check] != undefined) {
return check;
}
if (check.toLowerCase() == "private" && privateEnabled) {
return "__user__";
}
}
}
// no pref in cookie, no preferred tenant in kibana, use GLOBAL, Private or the first tenant in the list
if (globalEnabled) {
return "";
}
if (privateEnabled) {
return "__user__";
} else {
delete tenants[username];
}
// sort tenants by putting the keys in an array first
var tenantkeys = [];
var k;
for (k in tenants) {
tenantkeys.push(k);
}
tenantkeys.sort();
return tenantkeys[0];
}
validateTenant(username, requestedTenant, tenants, globalEnabled, privateEnabled) {
// delete user from tenants first to check if we have a tenant to choose from at all
// keep original preferences untouched, we need the original values again
// http://stackoverflow.com/questions/728360/how-do-i-correctly-clone-a-javascript-object
var tenantsCopy = JSON.parse(JSON.stringify(tenants));
delete tenantsCopy[username];
// sanity check: no global, no private, no other tenants -> no tenant available
if (!globalEnabled && !privateEnabled && _.isEmpty(tenantsCopy)) {
return null;
}
// requested tenant accessible for user
if (tenants[requestedTenant] != undefined) {
return requestedTenant;
}
if ((requestedTenant == "__user__" || requestedTenant == "private") && tenants[username] && privateEnabled) {
return "__user__";
}
if ((requestedTenant == "global" || requestedTenant === '') && globalEnabled) {
return "";
}
return null;
}
}

View File

@ -0,0 +1,71 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
/**
* Security plugin extension for the Elasticsearch Javascript client.
*/
import util from 'util';
export default function (Client, config, components) {
const ca = components.clientAction.factory;
Client.prototype.opendistro_security = components.clientAction.namespaceFactory();
Client.prototype.opendistro_security.prototype.authinfo = ca({
url: {
fmt: '/_opendistro/_security/authinfo'
}
});
Client.prototype.opendistro_security.prototype.multitenancyinfo = ca({
url: {
fmt: '/_opendistro/_security/kibanainfo'
}
});
Client.prototype.opendistro_security.prototype.tenantinfo = ca({
url: {
fmt: '/_opendistro/_security/tenantinfo'
}
});
Client.prototype.opendistro_security.prototype.authtoken = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_opendistro/_security/api/authtoken'
}
});
};

View File

@ -0,0 +1,197 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import _ from 'lodash';
import Boom from 'boom';
import elasticsearch from 'elasticsearch';
import SecurityConfigurationPlugin from './opendistro_security_configuration_plugin';
import wrapElasticsearchError from '../../backend/errors/wrap_elasticsearch_error';
import NotFoundError from '../../backend/errors/not_found';
import filterAuthHeaders from '../../auth/filter_auth_headers';
import Joi from 'joi'
import internalusers_schema from '../validation/internalusers'
import actiongroups_schema from '../validation/actiongroups'
import roles_schema from '../validation/roles'
import rolesmapping_schema from '../validation/rolesmapping'
/**
* The Security backend.
*/
export default class SecurityConfigurationBackend {
constructor(server) {
const config = Object.assign({ plugins: [SecurityConfigurationPlugin], auth: true }, server.config().get('elasticsearch'));
this._cluster = server.plugins.elasticsearch.createCluster('configuration',
config
);
this._client = this._cluster._noAuthClient;
// the es config for later use
this._esconfig = server.config().get('elasticsearch');
this.getValidator = (resourceName) => {
switch (resourceName) {
case 'internalusers':
return internalusers_schema;
case 'actiongroups':
return actiongroups_schema;
case 'rolesmapping':
return rolesmapping_schema;
case 'roles':
return roles_schema;
default:
throw new Error('Unknown resource');
}
}
}
async restapiinfo(headers) {
try {
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
const response = await this._client.opendistro_security.restapiinfo({
headers: authHeaders
});
return response
} catch(error) {
throw wrapElasticsearchError(error);
}
}
async indices(headers) {
try {
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
const response = await this._client.opendistro_security.indices({
headers: authHeaders
});
return response;
} catch(error) {
throw wrapElasticsearchError(error);
}
}
async list(headers, resourceName) {
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
try {
const response = await this._client.opendistro_security.listResource({
resourceName: resourceName,
headers: authHeaders
});
return response;
} catch (error) {
throw wrapElasticsearchError(error);
}
}
async get(headers, resourceName, id) {
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
try {
const response = await this._client.opendistro_security.getResource({
resourceName: resourceName,
id,
headers: authHeaders
});
var jsonRes = JSON.parse(response)
return jsonRes[id];
} catch (error) {
if (error.status === 404) {
throw new NotFoundError();
}
throw wrapElasticsearchError(error);
}
}
async save(headers, resourceName, id, body) {
const result = Joi.validate(body, this.getValidator(resourceName));
if (result.error) {
throw Boom.create(500, "Resource not valid");
}
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
try {
const response = await this._client.opendistro_security.saveResource({
resourceName: resourceName,
id,
body: body,
headers: authHeaders
});
return response;
} catch (error) {
if (error.status === 404) {
throw new NotFoundError();
}
throw wrapElasticsearchError(error);
}
}
async delete(headers, resourceName, id) {
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
try {
return await this._client.opendistro_security.deleteResource({
resourceName: resourceName,
id,
headers: authHeaders
});
} catch (error) {
if (error.status === 404) {
throw new NotFoundError();
}
throw wrapElasticsearchError(error);
}
}
async clearCache(headers, certificates) {
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
try {
const response = await this._client.opendistro_security.clearCache({
headers: authHeaders
});
return response;
} catch (error) {
throw wrapElasticsearchError(error);
}
}
async validateDls(headers, indexname, body) {
const authHeaders = filterAuthHeaders(headers, this._esconfig.requestHeadersWhitelist);
try {
const response = await this._client.opendistro_security.validateDls({
body: body,
headers: authHeaders
});
return response;
} catch (error) {
throw wrapElasticsearchError(error);
}
}
}

View File

@ -0,0 +1,174 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import util from 'util';
import Joi from 'joi';
export default function (Client, config, components) {
const ca = components.clientAction.factory;
Client.prototype.opendistro_security = components.clientAction.namespaceFactory();
Client.prototype.opendistro_security.prototype.restapiinfo = ca({
url: {
fmt: '/_opendistro/_security/api/permissionsinfo'
}
});
Client.prototype.opendistro_security.prototype.indices = ca({
url: {
fmt: '/_all/_mapping/field/*'
}
});
/**
* Returns a Security resource configuration.
*
* Sample response:
*
* {
* "user": {
* "hash": "#123123"
* }
* }
*/
Client.prototype.opendistro_security.prototype.listResource = ca({
url: {
fmt: '/_opendistro/_security/api/<%=resourceName%>',
req: {
resourceName: {
type: 'string',
required: true
}
}
}
});
/**
* Creates a Security resource instance.
*
* At the moment Security does not support conflict detection,
* so this method can be effectively used to both create and update resource.
*
* Sample response:
*
* {
* "status": "CREATED",
* "message": "User username created"
* }
*/
Client.prototype.opendistro_security.prototype.saveResource = ca({
method: 'PUT',
needBody: true,
url: {
fmt: '/_opendistro/_security/api/<%=resourceName%>/<%=id%>',
req: {
resourceName: {
type: 'string',
required: true
},
id: {
type: 'string',
required: true
}
}
}
});
/**
* Returns a Security resource instance.
*
* Sample response:
*
* {
* "user": {
* "hash": '#123123'
* }
* }
*/
Client.prototype.opendistro_security.prototype.getResource = ca({
method: 'GET',
url: {
fmt: '/_opendistro/_security/api/<%=resourceName%>/<%=id%>',
req: {
resourceName: {
type: 'string',
required: true
},
id: {
type: 'string',
required: true
}
}
}
});
/**
* Deletes a Security resource instance.
*/
Client.prototype.opendistro_security.prototype.deleteResource = ca({
method: 'DELETE',
url: {
fmt: '/_opendistro/_security/api/<%=resourceName%>/<%=id%>',
req: {
resourceName: {
type: 'string',
required: true
},
id: {
type: 'string',
required: true
}
}
}
});
/**
* Deletes a Security resource instance.
*/
Client.prototype.opendistro_security.prototype.clearCache = ca({
method: 'DELETE',
url: {
fmt: '/_opendistro/_security/api/cache',
}
});
Client.prototype.opendistro_security.prototype.validateDls = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_validate/query?explain=true'
}
});
};

View File

@ -0,0 +1,11 @@
/**
* Authentication backend resource identifiers.
*/
// TODO: get rid of resources mapping, or do it once
export default {
CONFIG: 'config',
INTERNAL_USER: 'InternalUser',
ROLE: 'Role',
ROLEMAPPING: 'RoleMapping',
ACTIONGROUP: 'ActionGroup'
};

View File

@ -0,0 +1,229 @@
import Boom from 'boom';
import Joi from 'joi';
import Resources from './../resources';
/**
* The backend API allows to manage the backend configuration.
*
* NOTE: All routes are POST since the REST API requires an admin certificat to access
*
*/
export default function (pluginRoot, server, APP_ROOT, API_ROOT) {
const config = server.config();
const backend = server.plugins.opendistro_security.getSecurityConfigurationBackend();
/**
* Returns a list of resource instances.
*
*/
server.route({
method: 'GET',
path: `${API_ROOT}/configuration/{resourceName}`,
handler: {
async: async (request, reply) => {
try {
const results = await backend.list(request.headers, request.params.resourceName);
return reply({
total: Object.keys(results).length,
data: results
});
} catch (error) {
if (error.isBoom) {
return reply(error);
}
throw error;
}
}
},
config: {
validate: {
params: {
resourceName: Joi.string().required()
}
}
}
});
/**
* Returns a resource instance.
*
* Response sample:
*
* {
* "id": "kibiuser",
* }
*/
server.route({
method: 'GET',
path: `${API_ROOT}/configuration/{resourceName}/{id}`,
handler: {
async: async (request, reply) => {
try {
const result = await backend.get(request.headers, request.params.resourceName, request.params.id);
return reply(result);
} catch (error) {
if (error.name === 'NotFoundError') {
return reply(Boom.notFound(`${request.params.id} not found.`));
} else {
if (error.isBoom) {
return reply(error);
}
throw error;
}
}
}
},
config: {
validate: {
params: {
resourceName: Joi.string().required(),
id: Joi.string().required()
}
}
}
});
/**
* Deletes a resource instance.
*
* Response sample:
*
* {
* "message": "Deleted user username"
* }
*/
server.route({
method: 'DELETE',
path: `${API_ROOT}/configuration/{resourceName}/{id}`,
handler: {
async: async (request, reply) => {
try {
const response = await backend.delete(request.headers, request.params.resourceName, request.params.id);
return reply({
message: response.message
});
} catch (error) {
if (error.isBoom) {
return reply(error);
}
throw error;
}
}
},
config: {
validate: {
params: {
resourceName: Joi.string().required(),
id: Joi.string().required()
}
}
}
});
/**
* Updates or creates a resource
*
* Request sample:
*
* {
* "password": "password"
* }
*/
server.route({
method: 'POST',
path: `${API_ROOT}/configuration/{resourceName}/{id}`,
handler: {
async: async (request, reply) => {
try {
const response = await backend.save(request.headers, request.params.resourceName, request.params.id, request.payload);
return reply({
message: response.message
});
} catch (error) {
if (error.isBoom) {
return reply(error);
}
throw error;
}
}
}
});
server.route({
method: 'DELETE',
path: `${API_ROOT}/configuration/cache`,
handler: {
async: async (request, reply) => {
try {
const response = await backend.clearCache(request.headers);
return reply({
message: response.message
});
} catch (error) {
if (error.isBoom) {
return reply(error);
}
throw error;
}
}
}
});
server.route({
method: 'GET',
path: `${API_ROOT}/restapiinfo`,
handler: {
async: async (request, reply) => {
try {
const response = await backend.restapiinfo(request.headers);
return reply(response);
} catch (error) {
if (error.isBoom) {
return reply(error);
}
throw error;
}
}
}
});
server.route({
method: 'GET',
path: `${API_ROOT}/configuration/indices`,
handler: (request, reply) => {
try {
let response = backend.indices(request.headers);
return reply(response);
} catch (error) {
if (error.isBoom) {
return reply(error);
}
throw error;
}
}
});
server.route({
method: 'POST',
path: `${API_ROOT}/configuration/validatedls/{indexName}`,
handler: {
async: async (request, reply) => {
try {
const response = await backend.validateDls(request.headers, request.params.indexName, request.payload);
return reply(response);
} catch (error) {
if (error.isBoom) {
return reply(error);
}
throw error;
}
}
}
});
}

View File

@ -0,0 +1,5 @@
import Joi from 'joi'
export default Joi.object().keys({
permissions: Joi.array().items(Joi.string())
});

View File

@ -0,0 +1,7 @@
import Joi from 'joi'
export default Joi.object().keys({
password: Joi.string().allow(''),
roles: Joi.array().items(Joi.string()),
attributes: Joi.object()
});

View File

@ -0,0 +1,16 @@
import Joi from 'joi'
export default Joi.object().keys({
cluster: Joi.array().items(Joi.string()),
tenants: Joi.object().pattern(/.*/, Joi.alternatives().try("RO", "RW")),
indices: Joi.object()
// indices, everything allowed
.pattern(/.*/, Joi.object({
// dls and fls keys, fixed
_dls_: Joi.string(),
_fls_: Joi.array().items(Joi.string())
})
// doctypes, everything allowed
.pattern(/.*/, Joi.array().items(Joi.string())
))
});

View File

@ -0,0 +1,7 @@
import Joi from 'joi'
export default Joi.object().keys({
backendroles: Joi.array().items(Joi.string()),
hosts: Joi.array().items(Joi.string()),
users: Joi.array().items(Joi.string())
});

View File

@ -0,0 +1,51 @@
import Promise from 'bluebird';
import elasticsearch from 'elasticsearch';
export default function (plugin, server) {
const callAdminAsKibanaUser = server.plugins.elasticsearch.getCluster('admin').callWithInternalUser;
const index = server.config().get('kibana.index');
const mappings = server.getKibanaIndexMappingsDsl();
function waitForElasticsearchGreen() {
return new Promise((resolve) => {
server.plugins.elasticsearch.status.once('green', resolve);
});
}
async function setupIndexTemplate() {
const adminCluster = server.plugins.elasticsearch.getCluster('admin');
try {
await callAdminAsKibanaUser('indices.putTemplate', {
name: `kibana_index_template:${index}_*`,
body: {
template: index+"_*",
settings: {
number_of_shards: 1,
},
mappings: server.getKibanaIndexMappingsDsl(),
},
});
} catch (error) {
server.log(['debug', 'setupIndexTemplate'], {
tmpl: 'Error setting up indexTemplate for SavedObjects: <%= err.message %>',
es: {
resp: error.body,
status: error.status,
},
err: {
message: error.message,
stack: error.stack,
},
});
throw new adminCluster.errors.ServiceUnavailable();
}
}
return {
setupIndexTemplate: setupIndexTemplate,
waitForElasticsearchGreen: waitForElasticsearchGreen
};
}

115
lib/hapi/auth.js Normal file
View File

@ -0,0 +1,115 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import Boom from 'boom';
import {assign} from 'lodash';
import User from '../auth/user';
const querystring = require('querystring')
export default function (pluginRoot, server, APP_ROOT, API_ROOT) {
const config = server.config();
const basePath = config.get('server.basePath');
const unauthenticatedRoutes = config.get('opendistro_security.basicauth.unauthenticated_routes');
// START add default unauthenticated routes
// END add default unauthenticated routes
const cookieConfig = {
password: config.get('opendistro_security.cookie.password'),
cookie: config.get('opendistro_security.cookie.name'),
isSecure: config.get('opendistro_security.cookie.secure'),
validateFunc: pluginRoot('lib/session/validate')(server),
ttl: config.get('opendistro_security.cookie.ttl')
};
server.auth.strategy('security_access_control_cookie', 'cookie', false, cookieConfig);
server.auth.scheme('security_access_control_scheme', (server, options) => ({
authenticate: (request, reply) => {
if (request.headers.authorization) {
var tmp = request.headers.authorization.split(' ');
var creds = new Buffer(tmp[1], 'base64').toString().split(':');
var username = creds[0];
var password = creds[1];
var credentials = server.plugins.opendistro_security.getSecurityBackend().getUser(username, password);
reply.continue({credentials});
return;
}
// let configured routes that are not under our control pass,
// for example /api/status to check Kibana status without a logged in user
if (unauthenticatedRoutes.includes(request.path)) {
var credentials = server.plugins.opendistro_security.getSecurityBackend().getServerUser();
reply.continue({credentials});
return;
};
server.auth.test('security_access_control_cookie', request, (error, credentials) => {
if (error) {
if (request.url.path.indexOf(API_ROOT) === 0 || request.method !== 'get') {
return reply(Boom.forbidden(error));
} else {
// If the session has expired, we may receive ajax requests that can't handle a 302 redirect.
// In this case, we trigger a 401 and let the interceptor handle the redirect on the client side.
if (request.headers && request.headers.accept && request.headers.accept.split(',').indexOf('application/json') > -1) {
// The redirectTo property in the payload tells the interceptor to handle this error.
return reply({message: 'Session expired', redirectTo: 'login'}).code(401);
}
const nextUrl = encodeURIComponent(request.url.path);
return reply.redirect(`${basePath}${APP_ROOT}/login?nextUrl=${nextUrl}`);
}
}
reply.continue({credentials});
});
}
}));
server.auth.strategy('security_access_control', 'security_access_control_scheme', true);
server.ext('onPostAuth', function (request, next) {
if (request.auth && request.auth.isAuthenticated) {
const backend = server.plugins.opendistro_security.getSecurityBackend();
return backend.getAuthHeaders(request.auth.credentials)
.then((headers) => {
assign(request.headers, headers);
return next.continue();
})
.catch((error) => {
server.log(['security', 'error'], `An error occurred while computing auth headers, clearing session: ${error}`);
request.auth.session.clear();
// redirect to login somehow?
return next.continue();
});
}
return next.continue();
});
}

60
lib/jwt/headers.js Normal file
View File

@ -0,0 +1,60 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import {assign} from 'lodash';
export default function (pluginRoot, server, APP_ROOT, API_ROOT) {
const config = server.config();
const basePath = config.get('server.basePath');
const backend = server.plugins.opendistro_security.getSecurityBackend();
const urlparamname = server.config().get('opendistro_security.jwt.url_param');
const headername = server.config().get('opendistro_security.jwt.header');
server.ext('onPostAuth', async function (request, next) {
var jwtBearer = request.state.security_jwt;
var jwtAuthParam = request.query[urlparamname];
if(jwtAuthParam != null) {
jwtBearer = jwtAuthParam;
next.state('security_jwt', jwtBearer);
}
if (jwtBearer != null) {
var headerValue = "Bearer " + jwtBearer;
var headers = {};
headers[headername] = headerValue;
assign(request.headers, headers);
}
return next.continue();
});
}

157
lib/multitenancy/headers.js Normal file
View File

@ -0,0 +1,157 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import {assign} from 'lodash';
export default function (pluginRoot, server, kbnServer, APP_ROOT, API_ROOT, authClass) {
const config = server.config();
const basePath = config.get('server.basePath');
const global_enabled = config.get('opendistro_security.multitenancy.tenants.enable_global');
const private_enabled = config.get('opendistro_security.multitenancy.tenants.enable_private');
const preferredTenants = config.get('opendistro_security.multitenancy.tenants.preferred');
const debugEnabled = config.get('opendistro_security.multitenancy.debug');
const backend = server.plugins.opendistro_security.getSecurityBackend();
const defaultSpaceId = 'default';
server.ext('onPreAuth', async function (request, next) {
// default is the tenant stored in the tenants cookie
const storedSelectedTenant = request.auth.securitySessionStorage.getStorage('tenant', {}).selected;
let selectedTenant = storedSelectedTenant;
if (debugEnabled) {
request.log(['info', 'security', 'tenant_storagecookie'], selectedTenant);
}
// check for tenant information in HTTP header. E.g. when using the saved objects API
if(request.headers.securitytenant || request.headers.security_tenant) {
selectedTenant = request.headers.securitytenant? request.headers.securitytenant : request.headers.security_tenant;
if (debugEnabled) {
request.log(['info', 'security', 'tenant_http_header'], selectedTenant);
}
}
// check for tenant information in GET parameter. E.g. when using a share link. Overwrites the HTTP header.
if (request.query && (request.query.security_tenant || request.query.securitytenant)) {
selectedTenant = request.query.security_tenant? request.query.security_tenant : request.query.securitytenant;
if (debugEnabled) {
request.log(['info', 'security', 'tenant_url_param'], selectedTenant);
}
}
// MT is only relevant for these paths
if (!request.path.startsWith("/elasticsearch") && !request.path.startsWith("/api") && !request.path.startsWith("/app") && request.path != "/" && !selectedTenant) {
return next.continue();
}
var response;
try {
if (authClass) {
authClass.assignAuthHeader(request);
}
response = await request.auth.securitySessionStorage.getAuthInfo(request.headers);
} catch(error) {
return next.continue();
}
// if we have a tenant, check validity and set it
if (typeof selectedTenant !== 'undefined' && selectedTenant !== null) {
selectedTenant = backend.validateTenant(response.user_name, selectedTenant, response.tenants, global_enabled, private_enabled);
} else {
// no tenant, choose configured, preferred tenant
try {
selectedTenant = backend.getTenantByPreference(request, response.user_name, response.tenants, preferredTenants, global_enabled, private_enabled);
} catch(error) {
// nothing
}
}
if(selectedTenant != storedSelectedTenant) {
// save validated tenant as preference
let prefcookie = backend.updateAndGetTenantPreferences(request, response.user_name, selectedTenant);
request.auth.securitySessionStorage.putStorage('tenant', {
selected: selectedTenant
});
next.state('security_preferences', prefcookie);
}
if (debugEnabled) {
request.log(['info', 'security', 'tenant_assigned'], selectedTenant);
}
if (selectedTenant != null) {
assign(request.headers, {'securitytenant' : selectedTenant});
}
// Test for default space?
if (config.has('xpack.spaces.enabled') && config.get('xpack.spaces.enabled') && (request.path === '/' || request.path.startsWith('/app'))) {
// We can't add a default space for RO tenants at the moment
if (selectedTenant && response.tenants[selectedTenant] === false) {
return next.continue();
}
const spacesClient = server.plugins.spaces.spacesClient.getScopedClient(request);
let defaultSpace = null;
try {
defaultSpace = await spacesClient.get(defaultSpaceId)
} catch(error) {
// Most likely not really an error, default space just not found
}
if (defaultSpace === null) {
try {
await spacesClient.create({
id: defaultSpaceId,
name: 'Default',
description: 'This is your default space!',
color: '#00bfb3',
_reserved: true
});
} catch(error) {
server.log(['security', 'error'], `An error occurred while creating a default space`);
}
}
}
return next.continue();
});
}

View File

@ -0,0 +1,117 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import _ from 'lodash';
import Boom from 'boom';
import elasticsearch from 'elasticsearch';
import wrapElasticsearchError from './../backend/errors/wrap_elasticsearch_error';
// import { KibanaMigrator } from '../../../../src/server/saved_objects/migrations';
async function migrateTenants (server) {
const backend = server.plugins.opendistro_security.getSecurityBackend();
try {
let tenantInfo = await backend.getTenantInfo(backend.getServerUserAuthHeader());
if (tenantInfo) {
let indexNames = Object.keys(tenantInfo);
for (var index = 0; index < indexNames.length; ++index) {
await migrateTenantIndex(indexNames[index], server);
}
}
} catch (error) {
server.log(['error', 'migration'], error);
throw error;
}
}
async function migrateTenantIndex(tenantIndexName, server) {
const {kbnServer} = mockKbnServer(server.kibanaMigrator.kbnServer, server, tenantIndexName);
const migrator = new KibanaMigrator({kbnServer});
await migrator.awaitMigration();
}
async function migrateTenant(tenantIndexName, force, server) {
const backend = server.plugins.opendistro_security.getSecurityBackend();
try {
let tenantInfo = await backend.getTenantInfo(backend.getServerUserAuthHeader());
if (tenantInfo) {
if (tenantInfo[tenantIndexName] || (force == true)) {
await migrateTenantIndex(tenantIndexName, server);
return {statusCode:200, message: tenantIndexName + " migrated."}
} else {
return Boom.badRequest('Index ' + tenantIndexName + ' not found or not a tenand index. Force migration: ' + force);
}
} else {
return Boom.badImplementation("Could not fetch tenant info.");
}
} catch (error) {
server.log(['error', 'migration'], error);
return wrapElasticsearchError(error);
}
}
function mockKbnServer(originalKbnServer, server, indexname) {
const kbnServer = {
version: originalKbnServer.version,
ready: originalKbnServer.ready,
uiExports: originalKbnServer.uiExports,
server: {
config: () => ({
get: ((name) => {
switch (name) {
case 'kibana.index':
return indexname;
case 'migrations.batchSize':
return originalKbnServer.server.config().get("migrations.batchSize");
case 'migrations.pollInterval':
return originalKbnServer.server.config().get("migrations.pollInterval");
case 'migrations.scrollDuration':
return originalKbnServer.server.config().get("migrations.scrollDuration");
default:
throw new Error(`Unexpected config ${name}`);
}
})
}),
log: function (tags, data, timestamp, _internal) {
server.log(tags, data, timestamp, _internal);
},
plugins: originalKbnServer.server.plugins
}
};
return { kbnServer };
}
module.exports.migrateTenants=migrateTenants;
module.exports.migrateTenant=migrateTenant;

103
lib/multitenancy/routes.js Normal file
View File

@ -0,0 +1,103 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import Boom from 'boom';
import Joi from 'joi';
import indexTemplate from '../elasticsearch/setup_index_template';
import { migrateTenant } from './migrate_tenants';
module.exports = function (pluginRoot, server, kbnServer, APP_ROOT, API_ROOT) {
const backend = server.plugins.opendistro_security.getSecurityBackend();
const { setupIndexTemplate } = indexTemplate(this, server);
const debugEnabled = server.config().get("opendistro_security.multitenancy.debug");
server.route({
method: 'POST',
path: `${API_ROOT}/multitenancy/tenant`,
handler: (request, reply) => {
var username = request.payload.username;
var selectedTenant = request.payload.tenant;
var prefs = backend.updateAndGetTenantPreferences(request, username, selectedTenant);
request.auth.securitySessionStorage.putStorage('tenant', {
selected: selectedTenant,
});
if (debugEnabled) {
request.log(['info', 'security', 'tenant_POST'], selectedTenant);
}
return reply(request.payload.tenant).state('security_preferences', prefs);
}
});
server.route({
method: 'GET',
path: `${API_ROOT}/multitenancy/tenant`,
handler: (request, reply) => {
let selectedTenant = request.auth.securitySessionStorage.getStorage('tenant', {}).selected;
if (debugEnabled) {
request.log(['info', 'security', 'tenant_GET'], selectedTenant);
}
return reply(selectedTenant);
}
});
server.route({
method: 'GET',
path: `${API_ROOT}/multitenancy/info`,
handler: (request, reply) => {
let mtinfo = server.plugins.opendistro_security.getSecurityBackend().multitenancyinfo(request.headers);
return reply(mtinfo);
}
});
server.route({
method: 'POST',
path: `${API_ROOT}/multitenancy/migrate/{tenantindex}`,
handler: async (request, reply) => {
if (!request.params.tenantindex) {
return reply(Boom.badRequest, "Please provide a tenant index name.")
}
let forceMigration = false;
if (request.query.force && request.query.force == "true") {
forceMigration = true
}
let result = await migrateTenant(request.params.tenantindex, forceMigration, server);
reply (result);
}
});
}; //end module

332
lib/session/sessionPlugin.js Executable file
View File

@ -0,0 +1,332 @@
import MissingTenantError from "../auth/errors/missing_tenant_error";
import MissingRoleError from "../auth/errors/missing_role_error";
var Hoek = require('hoek');
var Joi = require('joi');
/**
* Name of the cookie where we store additional session information, such as authInfo
* @type {string}
*/
const storageCookieName = 'security_storage';
let internals = {};
internals.config = Joi.object({
authType: Joi.string().allow(null),
authHeaderName: Joi.string(),
authenticateFunction: Joi.func(),
validateAvailableTenants: Joi.boolean().default(true),
validateAvailableRoles: Joi.boolean().default(true)
}).required();
exports.register = async function (server, options, next) {
let results = Joi.validate(options, internals.config);
Hoek.assert(!results.error, results.error);
let settings = results.value;
// @todo Don't register e.g. authenticate() when we have Kerberos or Proxy-Auth?
server.ext('onPreAuth', function (request, reply) {
request.auth.securitySessionStorage = {
/**
* Tries to authenticate a user. If multitenancy is enabled, we also try to validate that the
* user has at least one valid tenant
* @param {object} credentials
* @returns {Promise<*>}
*/
authenticate: async function(credentials, options = {}) {
try {
// authResponse is an object with .session and .user
const authResponse = await settings.authenticateFunction(credentials, options);
return this._handleAuthResponse(credentials, authResponse);
} catch(error) {
// Make sure we clear any existing cookies if something went wrong
this.clear();
throw error;
}
},
authenticateWithHeaders: async function(headers, credentials = {}, options = {}) {
try {
let user = await server.plugins.opendistro_security.getSecurityBackend().authenticateWithHeaders(headers);
let session = {
username: user.username,
credentials: credentials,
authType: settings.authType,
/**
* Used later to signal that we should not assign any specific auth header in AuthType
*/
assignAuthHeader: false
};
let sessionTTL = server.config().get('opendistro_security.session.ttl')
if(sessionTTL) {
session.expiryTime = Date.now() + sessionTTL
}
const authResponse = {
session,
user
};
return this._handleAuthResponse(credentials, authResponse)
} catch(error) {
// Make sure we clear any existing cookies if something went wrong
this.clear();
throw error;
}
},
/**
* Normalized response after an authentication
* @param credentials
* @param authResponse
* @returns {*}
* @private
*/
_handleAuthResponse: function(credentials, authResponse) {
// Make sure the user has a tenant that they can use
if(settings.validateAvailableTenants && server.config().get("opendistro_security.multitenancy.enabled") && ! server.config().get("opendistro_security.multitenancy.tenants.enable_global")) {
let privateTenantEnabled = server.config().get("opendistro_security.multitenancy.tenants.enable_private");
let allTenants = authResponse.user.tenants;
if (allTenants != null && ! privateTenantEnabled) {
delete allTenants[authResponse.user.username]
}
if (allTenants == null || Object.keys(allTenants).length === 0) {
throw new MissingTenantError('No tenant available for this user, please contact your system administrator.')
}
}
// Validate that the user has at least one valid role
if (settings.validateAvailableRoles && (!authResponse.user.roles || authResponse.user.roles.length === 0)) {
throw new MissingRoleError('No roles available for this user, please contact your system administrator.');
}
request.auth.session.set(authResponse.session);
this.setAuthInfo(authResponse.user.username, authResponse.user.backendroles, authResponse.user.roles, authResponse.user.tenants, authResponse.user.selectedTenant);
return authResponse;
},
/**
* Returns the current auth type
* @returns {void | null}
*/
getAuthType: function() {
return settings.authType;
},
getAuthHeaderName: function() {
return settings.authHeaderName;
},
/**
* Remember to call this in the correct lifecycle step. Calling this in onPreAuth will most likely return false because auth is not set up yet.
* @returns {boolean}
*/
isAuthenticated: function() {
if (request.auth && request.auth.isAuthenticated) {
return true;
}
return false;
},
/**
* Get the session credentials
* @returns {*}
*/
getSessionCredentials: function() {
if (this.isAuthenticated()) {
return request.auth.credentials;
}
return null;
},
/**
* Clears the cookies associated with the authenticated user
*/
clear: function() {
request.auth.session.clear();
reply.unstate(storageCookieName);
},
/**
* Get the content of the storage cookie or, when key is defined, a part of it
* @param key
* @param whenMissing - Allows for a default value when the given key is not in the cookie
* @returns {*}
*/
getStorage: function(key, whenMissing = null) {
let storage = request.state[storageCookieName];
if (! storage) {
return whenMissing;
}
if (! key) {
return storage;
}
if (key && storage[key]) {
return storage[key];
}
return whenMissing;
},
/**
* Store a value in the cookie
* @param key
* @param value
*/
putStorage: function(key, value) {
let storage = request.state[storageCookieName] || {};
if (! key) {
// Bail if we don't have a key, the cookie should contain an object
return;
}
storage[key] = value;
reply.state(storageCookieName, storage);
},
/**
* Clears the extra storage cookie only.
* Use .clear to remove both the auth and the storage cookies
*
*/
/**
* Clears the extra storage cookie only.
* Use .clear to remove both the auth and the storage cookies
*
* @param key - Pass a key to only delete a part of the storage cookie.
*/
clearStorage: function(key = null) {
if (key === null) {
reply.unstate(storageCookieName);
return;
}
let storage = this.getStorage();
if (storage && storage[key]) {
delete storage[key];
reply.state(storageCookieName, storage);
}
},
/**
* Store the result from the authinfo endpoint in the cookie.
* We don't store everything at the moment.
* @todo ask Jochen - custom_attribute_names could be too large for a cookie?
*
* @param user_name
* @param backend_roles
* @param roles
* @param tenants
* @param user_requested_tenant
*/
setAuthInfo: function(user_name, backend_roles, roles, tenants, user_requested_tenant) {
const authInfo = {
user_name,
backend_roles,
roles,
tenants,
user_requested_tenant
};
this.putStorage('authInfo', authInfo)
},
/**
* The storage cookie is coupled to the auth cookie, so we try to validate it similar to
* how we would validate the auth cookie
* @returns {*}
*/
validateStorageCookie: function() {
let sessionStorage = this.getStorage();
let authSession = this.getSessionCredentials();
// If we have an existing storage session and an existing auth session,
// we can assume that they are connected. We should validate that
// the auth session hasn't expired
// @todo Is this really necessary? We write the authInfo every time
// that we login, and we may need to provide the authInfo even
// if we don't have an auth session (Kerberos?) @Jochen
if (sessionStorage && authSession) {
if (authSession.exp && authSession.exp < Math.floor(Date.now() / 1000)) {
sessionStorage = null;
}
if (authSession.expiryTime && authSession.expiryTime < Date.now()) {
sessionStorage = null;
}
}
return sessionStorage;
},
/**
* Retrieves the authinfo from the storage cookie, if available.
* If not available, we pass the request headers to the backend
* and get the authinfo directly from there
*
* @returns {Promise<*>}
*/
getAuthInfo: async function() {
// See if we have the value in the cookie
if (this.authType !== null) {
let sessionStorage = this.validateStorageCookie();
if (sessionStorage && sessionStorage.authInfo) {
return sessionStorage.authInfo;
}
}
try {
let authInfo = await server.plugins.opendistro_security.getSecurityBackend().authinfo(request.headers);
// Don't save the authInfo in the cookie for e.g. Kerberos and Proxy-Auth
if (this.authType !== null && this.isAuthenticated()) {
this.setAuthInfo(authInfo.user_name, authInfo.backend_roles, authInfo.roles, authInfo.tenants, authInfo.user_requested_tenant);
}
return authInfo;
} catch (error) {
// Remove the storage cookie if something went wrong
if (this.authType !== null) {
reply.unstate(storageCookieName);
}
throw error;
}
}
};
return reply.continue();
});
next();
};
exports.register.attributes = {
name: 'security-session-storage'
};

65
lib/session/validate.js Normal file
View File

@ -0,0 +1,65 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import { assign } from 'lodash';
import InvalidSessionError from '../auth/errors/invalid_session_error';
export default function (server) {
const config = server.config();
const sessionTTL = config.get('opendistro_security.session.ttl');
const sessionKeepAlive = config.get('opendistro_security.session.keepalive');
return function validate(request, session, callback) {
try {
const backend = server.plugins.opendistro_security.getSecurityBackend();
if (sessionTTL) {
if (!session.expiryTime || session.expiryTime < Date.now()) {
return callback(new InvalidSessionError('Session expired.'), false);
}
}
backend.authenticate(session.credentials).then((user) => {
if (sessionTTL && sessionKeepAlive) {
let extendedSession = {};
assign(extendedSession, session);
extendedSession.expiryTime = Date.now() + sessionTTL;
request.auth.session.set(extendedSession);
}
return callback(null, true, user);
}).catch((error) => {
return callback(new InvalidSessionError('Invalid session.', error), false);
});
} catch (error) {
return callback(new InvalidSessionError('Invalid session', error), false);
}
};
};

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "opendistro_security",
"version": "6.5.4",
"description": "Security features for kibana",
"main": "index.js",
"homepage": "https://github.com/mauve-hedgehog/opendistro-elasticsearch-security-kibana-plugin",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/mauve-hedgehog/opendistro-elasticsearch-security-kibana-plugin"
},
"dependencies": {
"@elastic/kibana-ui-framework": "0.0.11",
"bell": "^8.8.0",
"boom": "5.2.0",
"cookie": "^0.3.1",
"hapi": "^16.0.1",
"hapi-async-handler": "^1.0.3",
"hapi-auth-cookie": "^3.1.0",
"hapi-authorization": "^3.0.2",
"joi": "10.6.0",
"js-yaml": "^3.7.0",
"requirefrom": "^0.2.0",
"wreck": "10.x.x"
},
"devDependencies": {
"ui-select": "^0.19.8"
}
}

19
plugin.xml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0"?>
<assembly>
<id>plugin</id>
<formats>
<format>zip</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<fileSets>
<fileSet>
<directory>${project.basedir}/build</directory>
<outputDirectory>${file.separator}</outputDirectory>
<useDefaultExcludes>false</useDefaultExcludes>
<includes>
<include>kibana/**</include>
</includes>
<fileMode>0755</fileMode>
</fileSet>
</fileSets>
</assembly>

90
pom.xml Normal file
View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?><!-- Copyright 2015-2017 floragunn GmbH
Licensed 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. -->
<!--
~ Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
~
~ Licensed under the Apache License, Version 2.0 (the "License").
~ You may not use this file except in compliance with the License.
~ A copy of the License is located at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ or in the "license" file accompanying this file. This file 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.amazon.opendistroforelasticsearch</groupId>
<artifactId>opendistro_security_parent</artifactId>
<version>0.7.0.0</version>
</parent>
<groupId>com.amazon.opendistroforelasticsearch</groupId>
<artifactId>opendistro_security_kibana_plugin</artifactId>
<packaging>pom</packaging>
<version>0.7.0.0</version>
<name>Open Distro Security for Elasticsearch Kibana Plugin</name>
<description>Elasticsearch Security Kibana Plugin for Elasticsearch 6</description>
<url>https://github.com/mauve-hedgehog/opendistro-elasticsearch-security-kibana-plugin</url>
<inceptionYear>2016</inceptionYear>
<licenses>
<license>
<name>The Apache Software License, Version 2.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
<distribution>repo</distribution>
</license>
</licenses>
<properties>
<plugin.descriptor>${basedir}/plugin.xml</plugin.descriptor>
</properties>
<scm>
<url>https://github.com/mauve-hedgehog/opendistro-elasticsearch-security-kibana-plugin</url>
<connection>scm:git:git@github.com:mauve-hedgehog/opendistro-elasticsearch-security-kibana-plugin.git</connection>
<developerConnection>scm:git:git@github.com:mauve-hedgehog/opendistro-elasticsearch-security-kibana-plugin.git</developerConnection>
<tag>v0.7.0.0</tag>
</scm>
<issueManagement>
<system>GitHub</system>
<url>https://github.com/mauve-hedgehog/opendistro-elasticsearch-security-kibana-plugin/issues</url>
</issueManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<executions>
<execution>
<id>plugin</id>
<phase>package</phase>
<configuration>
<appendAssemblyId>false</appendAssemblyId>
<outputDirectory>${project.build.directory}/releases/</outputDirectory>
<descriptors>
<descriptor>${plugin.descriptor}</descriptor>
</descriptors>
</configuration>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,51 @@
<div class="security app-container ng-scope">
<div class="kuiLocalNav">
<div class="kuiLocalNavRow">
<div class="kuiLocalNavRow__section">
<div class="kuiLocalTitle">
Account Information
<br />
</div>
</div>
</div>
</div>
<div class="container">
<div class="row">
<div class="col-xs-12">
<div class="kuiHeaderBar" style="margin-bottom: 20px; margin-top: 30px;">
<div class="kuiHeaderBarSection">
<h2 class="kuiSubTitle">Username</h2>
</div>
</div>
<ul>
<li>{{ctrl.security_user.user_name}}</li>
</ul>
<div class="kuiHeaderBar" style="margin-bottom: 20px; margin-top: 30px;">
<div class="kuiHeaderBarSection">
<h2 class="kuiSubTitle">Roles</h2>
</div>
</div>
<p data-ng-repeat="role in ctrl.security_user.roles" style="margin-bottom: 10px;">
{{role}}
</p>
<div ng-show="ctrl.security_user.roles == 0" style="font-style: italic">No roles found, please check the role mapping for this user.</div>
<div class="kuiHeaderBar" style="margin-bottom: 20px; margin-top: 30px;">
<div class="kuiHeaderBarSection">
<h2 class="kuiSubTitle">Backend Roles</h2>
</div>
</div>
<p data-ng-repeat="backendrole in ctrl.security_user.backend_roles" style="margin-bottom: 10px;">
{{backendrole}}
</p>
<div ng-show="ctrl.security_user.backend_roles.length == 0" style="font-style: italic">No backend roles found.</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,67 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import chrome from 'ui/chrome';
import uiRoutes from 'ui/routes';
import { uiModules } from 'ui/modules';
import { toastNotifications } from 'ui/notify';
import 'ui/autoload/styles';
import infoTemplate from './accountinfo.html';
uiRoutes.enable();
uiRoutes
.when('/', {
template: infoTemplate,
controller: 'accountInfoNavController',
controllerAs: 'ctrl'
});
uiModules
.get('app/security-accountinfo')
.controller('accountInfoNavController', function ($http, $window, Private, security_resolvedInfo) {
var APP_ROOT = `${chrome.getBasePath()}`;
var API_ROOT = `${APP_ROOT}/api/v1`;
$http.get(`${API_ROOT}/auth/authinfo`)
.then(
(response) => {
this.security_user = response.data;
},
(error) => {
toastNotifications.addDanger({
text: error.message,
});
}
);
});

View File

@ -0,0 +1,94 @@
import { uiModules } from 'ui/modules';
import { merge } from 'lodash';
import { uniq } from 'lodash';
import client from './client';
/**
* Action groups API client service.
*/
uiModules.get('apps/opendistro_security/configuration', [])
.service('backendActionGroups', function (backendAPI, Promise, $http, kbnUrl) {
const RESOURCE = 'actiongroups';
this.title = {
singular: 'action group',
plural: 'action groups'
};
this.newLabel = "Action Group name";
this.list = () => {
return backendAPI.list(RESOURCE);
};
this.listSilent = () => {
return backendAPI.listSilent(RESOURCE);
};
this.get = (id) => {
return backendAPI.get(RESOURCE, id);
};
this.save = (actiongroupname, data) => {
sessionStorage.removeItem("actiongroupsautocomplete");
sessionStorage.removeItem("actiongroupnames");
var data = this.preSave(data);
return backendAPI.save(RESOURCE, actiongroupname, data);
};
this.delete = (id) => {
sessionStorage.removeItem("actiongroupsautocomplete");
sessionStorage.removeItem("actiongroupnames");
return backendAPI.delete(RESOURCE, id);
};
this.listAutocomplete = (names) => {
return backendAPI.listAutocomplete(names);
};
this.emptyModel = () => {
var actiongroup = {};
actiongroup.permissions = [];
actiongroup.actiongroups = [];
return actiongroup;
};
this.preSave = (actiongroup) => {
var result = {};
var all = [];
all = all.concat(actiongroup.actiongroups);
all = all.concat(actiongroup.permissions);
// remove empty roles
all = all.filter(e => String(e).trim());
// remove duplicate roles
all = uniq(all);
result["permissions"] = all;
return result;
};
this.postFetch = (actiongroup) => {
// we need to support old and new format of actiongroups,
// normalize both formats to common representation
var permissionsArray = actiongroup;
// new SECURITY6 format, explicit permissions entry
if (actiongroup.permissions) {
permissionsArray = actiongroup.permissions;
}
// determine which format to use
permissionsArray = backendAPI.cleanArraysFromDuplicates(permissionsArray);
var permissions = backendAPI.sortPermissions(permissionsArray);
// if readonly flag is set for SECURITY6 format, add as well
if (actiongroup.readonly) {
permissions["readonly"] = actiongroup.readonly;
}
return permissions;
};
});

View File

@ -0,0 +1,245 @@
import { toastNotifications } from 'ui/notify';
import { uiModules } from 'ui/modules';
import { merge } from 'lodash';
import { uniq } from 'lodash';
import { isPlainObject } from 'lodash';
import { isEmpty } from 'lodash';
import chrome from 'ui/chrome';
/**
* Backend API client service.
*/
uiModules.get('apps/opendistro_security/configuration', [])
.service('backendAPI', function (Promise, $http, $window, kbnUrl, securityAccessControl) {
// Take the basePath configuration value into account
// @url https://www.elastic.co/guide/en/kibana/current/development-basepath.html
const AUTH_BACKEND_API_ROOT = chrome.addBasePath("/api/v1");
this.testConnection = () => {
return $http.post(`${AUTH_BACKEND_API_ROOT}/get/config`)
.then((response) => {
return 200;
})
.catch((error) => {
if (error.status) {
return error.status;
} else {
return 500;
}
});
};
this.get = function(resourceName, id) {
return $http.get(`${AUTH_BACKEND_API_ROOT}/configuration/${resourceName}/${id}`)
.then((response) => {
return response.data;
})
.catch((error) => {
if (error.status == 403) {
securityAccessControl.logout();
} else {
toastNotifications.addDanger({
title: 'Unable to load data.',
text: error.message,
});
}
throw error;
});
};
this.getSilent = function(resourceName, id, showError) {
showError = typeof showError !== 'undefined' ? showError : true;
return $http.get(`${AUTH_BACKEND_API_ROOT}/configuration/${resourceName}/${id}`)
.then((response) => {
return response.data;
})
.catch((error) => {
// nothing
});
};
this.save = (resourceName, id, data) => {
let url = `${AUTH_BACKEND_API_ROOT}/configuration/${resourceName}/${id}`;
return $http.post(url, data)
.then((response) => {
toastNotifications.addSuccess({
title: `'${id}' saved.`
});
})
.catch((error) => {
if (error.status == 403) {
securityAccessControl.logout();
} else {
toastNotifications.addDanger({
text: error.message
});
}
throw error;
});
};
this.delete = (resourceName, id) => {
return $http.delete(`${AUTH_BACKEND_API_ROOT}/configuration/${resourceName}/${id}`)
.then((response) => {
toastNotifications.addSuccess({
title: `'${id}' deleted.`
});
})
.catch((error) => {
if (error.status == 403) {
securityAccessControl.logout();
} else {
toastNotifications.addDanger({
title: 'Unable to delete data.',
text: error.message,
});
}
throw error;
});
};
this.list = (resourceName) => {
return $http.get(`${AUTH_BACKEND_API_ROOT}/configuration/${resourceName}`)
.then((response) => {
return response.data;
})
.catch((error) => {
if (error.status == 403) {
securityAccessControl.logout();
} else {
toastNotifications.addDanger({
text: error.message
});
}
toastNotifications.addDanger({
title: 'Unable to load data.',
text: error.message,
});
});
};
this.listSilent = (resourceName) => {
return $http.get(`${AUTH_BACKEND_API_ROOT}/configuration/${resourceName}`)
.then((response) => {
return response.data;
})
.catch((error) => {
// nothing
});
};
this.listAutocomplete = (names) => {
var completeList = [];
names.sort().forEach( function(name) {
var autocomplete = {};
autocomplete["name"] = name;
completeList.push(autocomplete);
} );
return completeList;
};
this.clearCache = () => {
return $http.delete(`${AUTH_BACKEND_API_ROOT}/configuration/cache`)
.then((response) => {
toastNotifications.addSuccess({
title: response.data.message
});
})
.catch((error) => {
if (error.status == 403) {
securityAccessControl.logout();
} else {
toastNotifications.addDanger({
title: 'Unable to clear cache.',
text: error.message,
});
}
throw error;
});
};
this.cleanArraysFromDuplicates = function(theobject) {
// We assume we don't have any mixed arrays,
// i.e. only arrays of one type
if (Array.isArray(theobject) && !isEmpty(theobject)) {
var firstEntry = theobject[0];
// string arrays, clean it
if (isString(firstEntry)) {
return this.cleanArray(theobject);
}
// object array, traverse down
if (isPlainObject(firstEntry)) {
for(var i = 0; i<theobject.length; i++) {
theobject[i] = this.cleanArraysFromDuplicates(theobject[i]);
}
}
// something else ...
return theobject;
}
// Object, traverse keys
if (isPlainObject(theobject)) {
var keys = Object.keys(theobject);
for (var i = 0; i < keys.length; i++) {
theobject[keys[i]] = this.cleanArraysFromDuplicates(theobject[keys[i]])
}
}
return theobject;
}
this.mergeCleanArray = (array1, array2) => {
var merged = [];
if (array1){
merged = merged.concat(array1);
}
if (array2) {
merged = merged.concat(array2);
}
merged = this.cleanArray(merged);
return merged;
};
this.cleanArray = (thearray) => {
if (!thearray) {
return [];
}
if (!Array.isArray(thearray)) {
return;
}
// remove empty entries
thearray = thearray.filter(e => String(e).trim());
// remove duplicate entries
thearray = uniq(thearray);
return thearray;
};
this.sortPermissions = (permissionsArray) => {
var actiongroups = [];
var permissions = [];
if (permissionsArray && Array.isArray(permissionsArray)) {
permissionsArray.forEach(function (entry) {
if (entry.startsWith("cluster:") || entry.startsWith("indices:")) {
permissions.push(entry);
} else {
actiongroups.push(entry);
}
});
}
return {
actiongroups: actiongroups,
permissions: permissions
}
};
// taken from lodash, not provided by Kibana
var isString = function(val) {
return typeof val === 'string' || ((!!val && typeof val === 'object') && Object.prototype.toString.call(val) === '[object String]');
}
});

View File

@ -0,0 +1,91 @@
import { uiModules } from 'ui/modules';
import { merge } from 'lodash';
import { uniq } from 'lodash';
import client from './client';
/**
* Internal users API client service.
*/
uiModules.get('apps/opendistro_security/configuration', [])
.service('backendInternalUsers', function (backendAPI, Promise, $http) {
const RESOURCE = 'internalusers';
this.title = {
singular: 'internal user',
plural: 'internal users'
};
this.newLabel = "Username";
this.list = () => {
return backendAPI.list(RESOURCE);
};
this.get = (username) => {
return backendAPI.get(RESOURCE, username);
};
this.save = (username, data) => {
data = this.preSave(data);
return backendAPI.save(RESOURCE, username, data);
};
this.delete = (username) => {
return backendAPI.delete(RESOURCE, username);
};
this.emptyModel = () => {
var user = {};
user["password"] = "";
user["passwordConfirmation"] = "";
user.roles = [];
user.attributesArray = [];
return user;
};
this.preSave = (user) => {
delete user["passwordConfirmation"];
// remove empty roles
user.roles = user.roles.filter(e => String(e).trim());
// remove duplicate roles
user.roles = uniq(user.roles);
// attribiutes
user["attributes"] = {};
for (var i = 0, l = user.attributesArray.length; i < l; i++) {
var entry = user.attributesArray[i];
if (entry && entry.key != "") {
user.attributes[entry.key] = entry.value;
}
}
delete user["attributesArray"];
return user;
};
this.postFetch = (user) => {
user = backendAPI.cleanArraysFromDuplicates(user);
delete user["hash"];
user["password"] = "";
user["passwordConfirmation"] = "";
if (!user.roles) {
user.roles = [];
}
// transform user attributes to object
user["attributesArray"] = [];
if (user.attributes) {
var attributeNames = Object.keys(user.attributes).sort();
attributeNames.forEach(function(attributeName){
user.attributesArray.push(
{
key: attributeName,
value: user.attributes[attributeName]
}
);
});
}
return user;
};
});

View File

@ -0,0 +1,322 @@
import { uiModules } from 'ui/modules';
import { isEmpty } from 'lodash';
import client from './client';
/**
* Role mappings API client service.
*/
uiModules.get('apps/opendistro_security/configuration', [])
.service('backendRoles', function (backendAPI, Promise, $http) {
const RESOURCE = 'roles';
this.title = {
singular: 'role',
plural: 'roles'
};
this.newLabel = "Role name";
this.list = () => {
return backendAPI.list(RESOURCE);
};
this.listSilent = () => {
return backendAPI.listSilent(RESOURCE);
};
this.listAutocomplete = (names) => {
return backendAPI.listAutocomplete(names);
};
this.get = (id) => {
return backendAPI.get(RESOURCE, id);
};
this.save = (rolename, data) => {
sessionStorage.removeItem("rolesautocomplete");
sessionStorage.removeItem("rolenames");
var resourceCopy = JSON.parse(JSON.stringify(data));
var data = this.preSave(resourceCopy);
return backendAPI.save(RESOURCE, rolename, data);
};
this.delete = (id) => {
sessionStorage.removeItem("rolesautocomplete");
sessionStorage.removeItem("rolenames");
return backendAPI.delete(RESOURCE, id);
};
this.emptyModel = () => {
var role = {};
role["cluster"] = [""];
role["indices"] = {};
role["tenants"] = {};
return role;
};
this.addEmptyIndex = (role, indexname, doctypename) => {
if (!role.indices) {
role["indices"] = {};
}
if (!role.indices[indexname]) {
role.indices[indexname] = {};
role.dlsfls[indexname] = {
_dls_: "",
_fls_: [],
_masked_fields_: [],
_flsmode_: "whitelist"
};
}
role.indices[indexname][doctypename] = {
"actiongroups": [""],
"permissions": []
};
}
this.preSave = (role) => {
delete role["indexnames"];
// merge cluster permissions
var clusterpermissions = backendAPI.mergeCleanArray(role.cluster.actiongroups, role.cluster.permissions);
// delete tmp permissions
delete role.cluster["actiongroups"];
delete role.cluster["permissions"];
role.cluster = clusterpermissions;
// same for each index and each doctype
for (var indexname in role.indices) {
var index = role.indices[indexname];
for (var doctypename in index) {
var doctype = index[doctypename];
var doctypepermissions = backendAPI.mergeCleanArray(doctype.actiongroups, doctype.permissions);
delete doctype["actiongroups"];
delete doctype["permissions"];
index[doctypename] = doctypepermissions;
}
// set field prefixes according to FLS mode
this.setFlsModeToFields(role.dlsfls[indexname]);
// move back dls and fls
var dlsfls = role.dlsfls[indexname];
if(dlsfls) {
if (dlsfls["_dls_"].length > 0) {
// remove any formatting
var dls = dlsfls["_dls_"];
try {
var dlsJsonObject = JSON.parse(dls);
dls = JSON.stringify(dlsJsonObject);
} catch (exception) {
// no valid json, keep as is.
}
index["_dls_"] = dls.replace(/(\r\n|\n|\r|\t)/gm,"");;
}
if (dlsfls["_fls_"].length > 0) {
index["_fls_"] = dlsfls["_fls_"];
}
if (dlsfls["_masked_fields_"].length > 0) {
index["_masked_fields_"] = dlsfls["_masked_fields_"];
}
}
}
delete role["dlsfls"];
// tenants
role["tenants"] = {};
for (var i = 0, l = role.tenantsArray.length; i < l; i++) {
var tenant = role.tenantsArray[i];
if (tenant && tenant.name != "") {
role.tenants[tenant.name] = tenant.permissions;
}
}
delete role["tenantsArray"];
return role;
};
this.postFetch = (role) => {
role = backendAPI.cleanArraysFromDuplicates(role);
// separate action groups and single permissions on cluster level
var clusterpermissions = backendAPI.sortPermissions(role.cluster);
role["cluster"] = {};
role.cluster["actiongroups"] = clusterpermissions.actiongroups;
role.cluster["permissions"] = clusterpermissions.permissions;
// move dls and fls to separate section on top level
// otherwise its on the same level as the document types
// and it is hard to separate them in the views. We
// should think about restructuring the config here, but
// for the moment we're fiddling with the model directly
role.dlsfls = {};
if (role.indices) {
// flat list of indexnames, can't be done in view
role["indexnames"] = Object.keys(role.indices).sort();
for (var indexname in role.indices) {
var index = role.indices[indexname];
var dlsfls = {
_dls_: "",
_fls_: [],
_masked_fields_: [],
_flsmode_: "whitelist"
};
if (index["_dls_"]) {
dlsfls._dls_ = index["_dls_"];
}
if (index["_fls_"]) {
dlsfls._fls_ = index["_fls_"];
}
if (index["_masked_fields_"]) {
dlsfls._masked_fields_ = index["_masked_fields_"];
}
delete role.indices[indexname]["_fls_"];
delete role.indices[indexname]["_dls_"];
delete role.indices[indexname]["_masked_fields_"];
role.dlsfls[indexname] = dlsfls;
// determine the fls mode and strip any prefixes
this.determineFlsMode(role.dlsfls[indexname]);
// sort permissions into actiongroups and single permissions
for (var doctypename in index) {
var doctype = index[doctypename];
var doctypepermissions = backendAPI.sortPermissions(doctype);
doctype = {
actiongroups: doctypepermissions.actiongroups,
permissions: doctypepermissions.permissions
}
index[doctypename] = doctype;
}
}
} else {
role.indices = {};
}
// transform tenants to object
role["tenantsArray"] = [];
if (role.tenants) {
var tenantNames = Object.keys(role.tenants).sort();
tenantNames.forEach(function(tenantName){
role.tenantsArray.push(
{
name: tenantName,
permissions: role.tenants[tenantName]
}
);
});
}
delete role["tenants"];
return role;
};
/**
* Determine the FLS mode (exclude/include) and
* strip the prefixes from the fields for
* display purposes. Rule here is that if one field
* is excluded, i.e. prefixed with a tilde, we
* assume exclude (blacklist) mode.
* @param dlsfls
*/
this.determineFlsMode = function (dlsfls) {
// default is whitelisting
dlsfls["_flsmode_"] = "whitelist";
// any fields to set?
var flsFields = dlsfls["_fls_"];
if (isEmpty(flsFields) || !Array.isArray(flsFields)) {
return;
}
for (var index = 0; index < flsFields.length; ++index) {
var field = flsFields[index];
if (field.startsWith("~")) {
// clean multiple tildes at the beginning, just in case
flsFields[index] = field.replace(/^\~+/, '');
dlsfls["_flsmode_"] = "blacklist";
}
}
}
/**
* Ensure that all fields are either prefixed with
* a tilde, or no field is prefixed with a tilde, based
* on the exclude/include mode of FLS.
* @param dlsfls
*/
this.setFlsModeToFields = function(dlsfls) {
if (!dlsfls) {
return;
}
// any fields to set?
var flsFields = dlsfls["_fls_"];
if (isEmpty(flsFields) || !Array.isArray(flsFields)) {
return;
}
for (var index = 0; index < flsFields.length; ++index) {
var field = flsFields[index];
// remove any tilde from beginning of string, in case
// the user has added it in addition to setting mode to blacklist
// We need just a single tilde here.
field = field.replace(/^\~+/, '');
if (!field.startsWith("~") && dlsfls["_flsmode_"] == "blacklist") {
flsFields[index] = "~" + field;
}
}
}
/**
* Checks whether a role definition is empty. Empty
* roles are not supported and cannot be saved. We need
* at least some index or clusterpermissions
* @param role
*/
this.isRoleEmpty = function (role) {
// clean duplicates and remove empty arrays
role.cluster.actiongroups = backendAPI.cleanArray(role.cluster.actiongroups);
role.cluster.permissions = backendAPI.cleanArray(role.cluster.permissions);
var clusterPermsEmpty = role.cluster.actiongroups.length == 0 && role.cluster.permissions.length == 0;
var indicesEmpty = this.checkIndicesStatus(role).allEmpty;
return clusterPermsEmpty && indicesEmpty;
}
this.checkIndicesStatus = function (role) {
// index, we need at least one index with one document type with one permissions
var indicesStatus = {
allEmpty: true,
faultyIndices: []
};
if (role.indices) {
var indexNames = Object.keys(role.indices);
indexNames.forEach(function(indexName) {
var docTypeNames = Object.keys(role.indices[indexName]);
docTypeNames.forEach(function(docTypeName) {
var doctype = role.indices[indexName][docTypeName];
// doctype with at least one permission
doctype.actiongroups = backendAPI.cleanArray(doctype.actiongroups);
doctype.permissions = backendAPI.cleanArray(doctype.permissions);
if ((doctype.actiongroups && doctype.actiongroups.length > 0) || (doctype.permissions && doctype.permissions.length > 0)) {
indicesStatus.allEmpty = false;
} else {
// empty doctype
indicesStatus.faultyIndices.push(indexName + " / " + docTypeName);
}
});
});
}
return indicesStatus;
}
});

View File

@ -0,0 +1,72 @@
import { uiModules } from 'ui/modules';
import { merge } from 'lodash';
import { uniq } from 'lodash';
import client from './client';
/**
* Role mappings API client service.
*/
uiModules.get('apps/opendistro_security/configuration', [])
.service('backendrolesmapping', function (backendAPI, Promise, $http) {
const RESOURCE = 'rolesmapping';
this.title = {
singular: 'role mapping',
plural: 'role mappings'
};
this.newLabel = "Role";
this.list = () => {
return backendAPI.list(RESOURCE);
};
this.get = (id) => {
return backendAPI.get(RESOURCE, id);
};
this.getSilent = (id) => {
return backendAPI.getSilent(RESOURCE, id);
};
this.save = (actiongroupname, data) => {
var data = this.preSave(data);
return backendAPI.save(RESOURCE, actiongroupname, data);
};
this.delete = (id) => {
return backendAPI.delete(RESOURCE, id);
};
this.emptyModel = () => {
var rolemapping = {};
rolemapping.users = [];
rolemapping.hosts = [];
rolemapping.backendroles = [];
return rolemapping;
};
this.preSave = (rolemapping) => {
rolemapping.users = this.cleanArray(rolemapping.users);
rolemapping.backendroles = this.cleanArray(rolemapping.backendroles);
rolemapping.hosts = this.cleanArray(rolemapping.hosts);
return rolemapping;
};
this.postFetch = (rolemapping) => {
rolemapping = backendAPI.cleanArraysFromDuplicates(rolemapping);
return rolemapping;
};
this.cleanArray = (thearray) => {
if (thearray && Array.isArray(thearray)) {
// remove empty entries
thearray = thearray.filter(e => String(e).trim());
// remove duplicate entries
thearray = uniq(thearray);
return thearray;
}
};
});

View File

@ -0,0 +1,26 @@
import { uiModules } from 'ui/modules';
import client from './client';
/**
* security configuration client service.
*/
uiModules.get('apps/opendistro_security/configuration', [])
.service('securityConfiguration', function (backendAPI, Promise, $http) {
const RESOURCE = 'config';
this.title = {
singular: 'Authentication / Authorization configuration',
plural: 'Authentication / Authorization configuration'
};
this.list = () => {
return backendAPI.list(RESOURCE);
};
this.postFetch = (securityconfig) => {
return securityconfig;
};
});

View File

@ -0,0 +1,419 @@
import { uiModules } from 'ui/modules';
import chrome from 'ui/chrome';
import { SavedObjectsClientProvider } from 'ui/saved_objects';
import { toastNotifications } from 'ui/notify';
import { get } from 'lodash';
import './directives/directives';
import client from './backend_api/client';
import { uniq } from 'lodash';
import { orderBy } from 'lodash';
import clusterpermissions from './permissions/clusterpermissions';
import indexpermissions from './permissions/indexpermissions';
import 'ui-select';
import 'ui-select/dist/select.css';
require ('./backend_api/actiongroups');
require ('./systemstate/systemstate');
const app = uiModules.get('apps/opendistro_security/configuration', ['ui.ace', 'ui.select']);
app.controller('securityBaseController', function ($scope, $element, $route, $window, $http, backendAPI, backendActionGroups, backendRoles, kbnUrl, systemstate) {
var APP_ROOT = `${chrome.getBasePath()}`;
var API_ROOT = `${APP_ROOT}/api/v1`;
// props of the child controller
$scope.service = null;
$scope.endpoint = null;
// loading state and loaded resources
$scope.numresources = "0";
$scope.loaded = false;
$scope.title = "Security Base Controller";
$scope.errorMessage = "";
$scope.query = "";
$scope.resource = {};
$scope.showEditor = false;
$scope.toggleEditorLabel = "Show JSON";
$scope.resourceAsJson = null;
$scope.accessState = "pending";
$scope.actiongroupNames = [];
$scope.roleNames = [];
// objects for autocomplete
$scope.actiongroupsAutoComplete = {};
$scope.rolesAutoComplete = {};
$scope.clusterpermissionsAutoComplete = clusterpermissions;
$scope.indexpermissionsAutoComplete = indexpermissions;
$scope.allpermissionsAutoComplete = indexpermissions.concat(clusterpermissions);
$scope.currentuser = "";
// modal delete dialogue
$scope.displayModal = false;
$scope.deleteModalResourceName = "";
// edit views modal delete dialogue
$scope.deleteFromEditModal = {
displayModal: false,
params: {},
header: 'Confirm Delete',
body: '',
onConfirm: null,
onClose: null
};
// img assets routes
$scope.roleMappingsSvgURL = APP_ROOT + "/plugins/opendistro_security/assets/role_mappings.svg";
$scope.rolesSvgURL = APP_ROOT + "/plugins/opendistro_security/assets/roles.svg";
$scope.actionGroupsSvgURL = APP_ROOT + "/plugins/opendistro_security/assets/action_groups.svg";
$scope.internalUserDatabaseSvgURL = APP_ROOT + "/plugins/opendistro_security/assets/internal_user_database.svg";
$scope.authenticationSvgURL = APP_ROOT + "/plugins/opendistro_security/assets/authentication.svg";
$scope.purgeCacheSvgURL = APP_ROOT + "/plugins/opendistro_security/assets/purge_cache.svg";
$scope.title = "Security Configuration";
$scope.initialiseStates = () => {
$scope.complianceFeaturesEnabled = systemstate.complianceFeaturesEnabled();
systemstate.loadRestInfo().then(function(){
$scope.accessState = "ok";
$scope.loadActionGroups();
$scope.loadRoles();
$scope.currentuser = systemstate.getRestApiInfo().user_name;
});
}
$scope.loadActionGroups = () => {
var cachedActionGroups = sessionStorage.getItem("actiongroupsautocomplete");
var cachedActionGroupNames = sessionStorage.getItem("actiongroupnames");
if (cachedActionGroups) {
$scope.actiongroupsAutoComplete = JSON.parse(cachedActionGroups);
}
if (cachedActionGroupNames) {
$scope.actiongroupNames = JSON.parse(cachedActionGroupNames);
}
if (cachedActionGroupNames && cachedActionGroups) {
return;
}
if(systemstate.endpointAndMethodEnabled("ACTIONGROUPS","GET")) {
backendActionGroups.listSilent().then((response) => {
$scope.actiongroupNames = Object.keys(response.data);
sessionStorage.setItem("actiongroupnames", JSON.stringify($scope.actiongroupNames));
$scope.actiongroupsAutoComplete = backendActionGroups.listAutocomplete($scope.actiongroupNames);
sessionStorage.setItem("actiongroupsautocomplete", JSON.stringify($scope.actiongroupsAutoComplete));
}, (error) => {
toastNotifications.addDanger({
title: 'Unable to load action groups',
text: error.message,
});
$scope.accessState = "forbidden";
});
}
}
$scope.loadRoles = () => {
var cachedRoles = sessionStorage.getItem("rolesautocomplete");
var cachedRoleNames = sessionStorage.getItem("rolenames");
if (cachedRoles) {
$scope.rolesAutoComplete = JSON.parse(cachedRoles);
}
if (cachedRoleNames) {
$scope.roleNames = JSON.parse(cachedRoleNames);
}
if (cachedRoles && cachedRoleNames) {
return;
}
if(systemstate.endpointAndMethodEnabled("ROLES","GET")) {
backendRoles.listSilent().then((response) => {
$scope.rolesAutoComplete = backendRoles.listAutocomplete(Object.keys(response.data));
$scope.roleNames = Object.keys(response.data);
sessionStorage.setItem("rolesautocomplete", JSON.stringify($scope.rolesAutoComplete));
sessionStorage.setItem("rolenames", JSON.stringify(Object.keys(response.data)));
}, (error) => {
toastNotifications.addDanger({
text: error.message,
});
$scope.accessState = "forbidden";
});
}
}
$scope.clearCache = function() {
backendAPI.clearCache();
}
$scope.getDocTypeAutocomplete = () => {
$scope.indexAutoComplete = backendAPI.indexAutocomplete();
}
$scope.endpointAndMethodEnabled = (endpoint, method) => {
return systemstate.endpointAndMethodEnabled(endpoint, method);
}
// +++ START common functions for all controllers +++
// --- Start navigation
$scope.edit = function(resourcename) {
kbnUrl.change('/' +$scope.endpoint.toLowerCase() + '/edit/' + resourcename );
}
$scope.new = function(query) {
kbnUrl.change('/' +$scope.endpoint.toLowerCase() + '/new?name='+query);
}
$scope.clone = function(resourcename) {
kbnUrl.change('/' +$scope.endpoint.toLowerCase() + '/clone/' + resourcename);
}
$scope.cancel = function () {
kbnUrl.change('/' +$scope.endpoint.toLowerCase() );
}
// --- End navigation
$scope.delete = function() {
$scope.displayModal = false;
var name = $scope.deleteModalResourceName;
$scope.deleteModalResourceName = "";
$scope.service.delete(name)
.then(() => $scope.cancel());
}
$scope.confirmDelete = function(resourcename) {
$scope.deleteModalResourceName = resourcename;
$scope.displayModal = true;
}
$scope.closeDeleteModal = () => {
$scope.deleteModalResourceName = "";
$scope.displayModal = false;
};
$scope.aceLoaded = (editor) => {
editor.session.setOptions({
tabSize: 2,
useSoftTabs: false
});
editor.$blockScrolling = Infinity;
editor.setShowPrintMargin(false);
};
$scope.aceRwLoaded = (editor) => {
editor.session.setOptions({
tabSize: 2,
useSoftTabs: false
});
editor.$blockScrolling = Infinity;
editor.setShowPrintMargin(false);
};
$scope.toggleEditor = (resource) => {
if ($scope.resourceAsJson == null) {
$scope.loadJSON(resource)
}
$scope.showEditor = !$scope.showEditor;
$scope.toggleEditorLabel = $scope.showEditor? "Hide JSON" : "Show JSON";
};
$scope.loadJSON = function(resource) {
// copy resource, we don't want to modify current edit session
var resourceCopy = JSON.parse(JSON.stringify(resource));
$scope.resourceAsJson = JSON.stringify($scope.service.preSave(resourceCopy), null, 2);
}
$scope.checkActionGroupExists = function (array, index, item) {
if($scope.actiongroupNames.indexOf(item) == -1) {
array[index] = "";
}
}
$scope.addArrayEntry = function (resource, fieldname, value) {
if(!resource[fieldname] || !Array.isArray(resource[fieldname])) {
resource[fieldname] = [];
}
resource[fieldname].push(value);
};
/**
* Remove an array entry after user confirmation, or when the user removes an empty entry
* @param {array} array
* @param {object} item
*/
let removeArrayEntry = function (array, item) {
var index = array.indexOf(item);
if (index > -1) {
array.splice(index, 1);
}
};
/**
* Ask for confirmation before deleting an entry
* @param {array} array
* @param {string} item
*/
$scope.confirmRemoveArrayEntry = function (array, item) {
if(!Array.isArray(array) || array.indexOf(item) === -1) {
return;
}
if (item && item.length > 0) {
$scope.deleteFromEditModal = {
displayModal: true,
header: 'Confirm Delete',
body: `Are you sure you want to delete '${item}'?`,
onConfirm: function() {
removeArrayEntry(array, item);
$scope.closeDeleteFromEditModal()
},
onClose: $scope.closeDeleteFromEditModal
};
} else {
removeArrayEntry(array, item);
}
};
/**
* Close the confirmation dialog
* @param {string} reason
*/
$scope.closeDeleteFromEditModal = function () {
$scope.deleteFromEditModal = {
displayModal: false,
params: null
};
};
/**
* @todo Remove when we use the new confirmation dialog everywhere
* @deprecated
* @param array
* @param item
*/
$scope.removeArrayEntry = function (array, item) {
if(!Array.isArray(array)) {
return;
}
if (item && item.length > 0) {
if (!confirm(`Are you sure you want to delete '${item}'?`)) {
return;
}
}
var index = array.indexOf(item);
array.splice(index, 1);
}
$scope.lastArrayEntryEmpty = function (array) {
if (!array || typeof array == 'undefined' || array.length == 0) {
return false;
}
var entry = array[array.length - 1];
if (typeof entry === 'undefined' || entry.length == 0) {
return true;
}
return false;
}
$scope.removeObjectKey = function (theobject, key) {
if (theobject[key]) {
if (confirm(`Are you sure you want to delete '${key}'?`)) {
delete theobject[key];
}
}
}
$scope.addObjectKey = function (theobject, key, value) {
theobject[key] = value;
}
$scope.sortObjectArray = function (objectArray, sortProperty) {
//return orderBy(objectArray, [sortProperty], ["asc"]);
return objectArray;
}
/**
* Ask for confirmation before deleting an entry
* @param {array} thearray
* @param {int} index
* @param {string} value
*/
$scope.confirmRemoveFromObjectArray = function(thearray, index, value) {
// We're not checking the value here, so we may need to adjust the body
let body = (value === '') ? 'Are you sure you want to delete this?' : `Are you sure you want to delete '${value}'?`;
$scope.deleteFromEditModal = {
displayModal: true,
header: 'Confirm Delete',
body: body,
onConfirm: function() {
thearray.splice(index, 1);
$scope.closeDeleteFromEditModal()
},
onClose: $scope.closeDeleteFromEditModal
}
};
/**
* Remove when we've tested all dialogs
* @deprecated
* @param thearray
* @param index
* @param value
*/
$scope.removeFromObjectArray = function (thearray, index, value) {
if (confirm(`Are you sure you want to delete '${value}'?`)) {
thearray.splice(index, 1);
}
}
$scope.addToObjectArray = function (thearray, value) {
return thearray.push(value);
}
// helper function to use Object.keys in templates
$scope.keys = function (object) {
if (object) {
return Object.keys(object).sort();
}
}
$scope.flatten = function (list, textAttribute) {
return uniq(list.reduce((result, item) => {
const text = item[textAttribute];
if (text) {
result.push(text);
}
return result;
}, [])).sort();
}
// --- init ---
$scope.initialiseStates();
});
app.filter('escape', function() {
return window.encodeURIComponent;
});
app.filter('unsafe', function($sce) {
return $sce.trustAsHtml;
});

View File

@ -0,0 +1,131 @@
<div ng-controller="securityBaseController">
<div class="security app-container" ng-controller="securityConfigurationController">
<securityc-header></securityc-header>
<div class="row">
<div class="col-xs-12" style="text-align: center;">
<h3 ng-if="accessState=='pending'" style="margin-top:0px;">Please wait ...</h3>
<p id="opendistro_security.label.errormessage" class="error-message" ng-show="errorMessage">{{ errorMessage }}</p>
</div>
</div>
<div class="row" ng-if="accessState=='forbidden'">
<div class="col-xs-12" style="text-align: center;">
<h3>You do not have permission to access the Security configuration. Please contact your System
Administrator.</h3>
</div>
</div>
<div class="row" ng-if="accessState=='notenabled'">
<div class="col-xs-12" style="text-align: center;">
<h3>The REST API module is not installed. Please contact your System Administrator.</h3>
</div>
</div>
<div ng-if="accessState=='ok'">
<div class="kuiViewContent kuiViewContent--constrainedWidth">
<div class="kuiViewContentItem">
<div class="kuiVerticalRhythm kuiVerticalRhythm--medium ">
<h2 class="kuiSubTitle" style="margin-bottom:10px;">Permissions and Roles</h2>
<div class="kuiGallery">
<a id="opendistro_security.link.rolesmapping" ng-if="endpointAndMethodEnabled('ROLESMAPPING', 'GET')" class="kuiGalleryItem ng-scope"
ng-href="#/rolesmapping"
tooltip="Map users, backend roles and hostnames to roles."
tooltip-placement="bottom" href="#/rolesmapping">
<div class="kuiGalleryButton__image">
<img src="{{roleMappingsSvgURL}}" width="56" />
</div>
<div class="kuiGalleryButton__label">
Role Mappings
</div>
</a>
<a id="opendistro_security.link.roles" ng-if="endpointAndMethodEnabled('ROLES', 'GET')" class="kuiGalleryItem ng-scope"
ng-href="#/roles" tooltip="Configure Roles and their permissions."
tooltip-placement="bottom" href="#/roles">
<div class="kuiGalleryButton__image">
<img src="{{rolesSvgURL}}" width="56" />
</div>
<div class="kuiGalleryButton__label">
Roles
</div>
</a>
<a id="opendistro_security.link.actiongroups" ng-if="endpointAndMethodEnabled('ACTIONGROUPS', 'GET')" class="kuiGalleryItem ng-scope"
ng-href="#/actiongroups"
tooltip="Configure named groups of permissions that can be applied to roles."
tooltip-placement="bottom" href="#/actiongroups">
<div class="kuiGalleryButton__image">
<img src="{{actionGroupsSvgURL}}" width="56" />
</div>
<div class="kuiGalleryButton__label">
Action Groups
</div>
</a>
</div>
</div>
<div class="kuiVerticalRhythm kuiVerticalRhythm--medium ">
<h2 class="kuiSubTitle" style="margin-bottom:10px;">Authentication Backends</h2>
<div class="kuiGallery">
<a id="opendistro_security.link.internalusers" ng-if="endpointAndMethodEnabled('INTERNALUSERS', 'GET')" class="kuiGalleryItem"
ng-href="#/internalusers"
tooltip="Use the Internal Users Database if you don't have any external authentication systems in place."
tooltip-placement="bottom" href="#/internalusers">
<div class="kuiGalleryButton__image">
<img src="{{internalUserDatabaseSvgURL}}" width="56" />
</div>
<div class="kuiGalleryButton__label">
Internal User Database
</div>
</a>
</div>
<div class="kuiVerticalRhythm kuiVerticalRhythm--medium ">
<h2 class="kuiSubTitle" style="margin-bottom:10px;">System</h2>
<div class="kuiGallery">
<a id="opendistro_security.link.securityconfig" ng-if="endpointAndMethodEnabled('SECURITYCONFIG', 'GET')" class="kuiGalleryItem ng-scope"
ng-href="#/securityconfiguration"
tooltip="View the configured authentication and authorization modules."
href="#/securityconfiguration">
<div class="kuiGalleryButton__image">
<img src="{{authenticationSvgURL}}" width="56" />
</div>
<div class="kuiGalleryButton__label">
Authentication & Authorization
</div>
</a>
<a id="opendistro_security.link.cache" ng-if="endpointAndMethodEnabled('CACHE', 'DELETE')" class="kuiGalleryItem ng-scope"
ng-click="clearCache()" tooltip="Purge all Security caches"
tooltip-placement="bottom">
<div class="kuiGalleryButton__image">
<img src="{{purgeCacheSvgURL}}" width="56" />
</div>
<div class="kuiGalleryButton__label">
Purge Cache
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,58 @@
/*
* Copyright 2015-2018 _floragunn_ GmbH
*
* Licensed 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.
*
*/
/*
* Portions Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file 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.
*/
import chrome from 'ui/chrome';
import uiRoutes from 'ui/routes';
import { uiModules } from 'ui/modules';
import internalusers from './sections/internalusers';
import actiongroups from './sections/actiongroups';
import rolesmapping from './sections/rolesmapping';
import roles from './sections/roles';
import securityconfiguration from './sections/securityconfiguration';
import 'ui/autoload/styles';
import 'plugins/opendistro_security/apps/configuration/configuration.less';
import 'ace';
import securityConfigurationController from './configuration_controller';
import template from './configuration.html';
require('ui/tooltip');
uiRoutes.enable();
uiRoutes
.when('/', {
template: template
});

View File

@ -0,0 +1,289 @@
@import "~ui/styles/variables/colors.less";
@import "~ui/styles/variables/bootstrap-mods.less";
h1 {
font-size: 1.6em;
font-weight: 600;
}
h2 {
font-size: 1.5em;
font-weight: 600;
}
h3 {
font-size: 1.3em;
font-weight: 600;
}
h5 {
font-size: 1.1em;
font-weight: 600;
margin-bottom: 5px;
margin-top: 15px;
}
.help {
cursor: help;
}
.error-message a {
color: red;
}
.container {
margin-right: auto;
margin-left: 0px;
padding-left: 15px;
padding-right: 15px;
}
@media (min-width: 768px) {
.container {
width: 750px;
}
}
@media (min-width: 992px) {
.container {
width: 970px;
}
}
@media (min-width: 1200px) {
.container {
width: 1170px;
}
}
.container-fluid {
margin-right: auto;
margin-left: 0px;
padding-left: 15px;
padding-right: 15px;
}
.ng-submitted input.ng-invalid {
border-color: #A30000 !important;
}
.kuiTextInput:invalid {
border-color: #DEDEDE;
}
.kuiBadge {
margin-left: 0px;
}
.topBottomMargin {
margin-bottom:20px;
margin-top:20px;
}
.editResourceForm {
width: 70%;
}
.brand-image {
max-width: 300px;
text-align: center;
}
.actions {
text-align: right;
vertical-align: middle;
}
.selected {
background-color: #eee;
}
.indexheading {
margin-top: 0px;
}
.resourcename {
margin-bottom: 10px;
margin-top: 0px;
}
.noresourcename {
font-style: italic;
color:#aaa;
font-size: 16px;
}
.labelhint {
font-style: italic;
color:#aaa;
font-weight: normal;
}
.marginbottom--small {
margin-bottom: 10px;
}
.tab-inactive {
color: #ccc;
}
.headerHeadline {
padding-left: 10px;
font-size: 14px;
font-weight: bold;
}
.kuiViewContent--constrainedWidth {
width: 100%;
max-width: 1100px;
margin-left: 0px;
margin-right: auto;
}
.kuiPanel--noborder {
border: none;
}
.kuiEmptyTablePrompt--nopadding {
padding: 0px;
}
.kuiGalleryButton {
width: 130px;
height: 130px;
padding: 0px;
}
.kuiGalleryButton__label {
font-size: 14px;
color: #191E23;
text-align: center;
max-width: 100%;
white-space: inherit;
overflow: hidden;
text-overflow: ellipsis;
}
.kuiTableRowCell {
padding: 7px 8px 8px;
}
.tableHeaderCellIndexGroups {
width:70%;
color:#000;
font-weight:bold;
}
.tableHeaderBold {
color:#000;
font-weight:bold;
}
.tableIndexGroups {
margin-bottom: 20px;
}
.cellAlignTop {
vertical-align: top;
}
.twowidth {
width:20%;
}
.fourwidth {
width:40%;
}
.fivewidth {
width:50%;
}
.sevenwidth {
width:70%;
}
.fullwidth {
width:100%;
}
.formsubmit {
margin-bottom: 30px;
margin-top: 10px;
}
.kuiLocalNav {
min-height: 60px;
margin-bottom: 20px;
}
.kuiLocalNavMain {
min-height: 30px;
margin-bottom: 20px;
}
.error-message {
font-weight: bold;
color: red;
margin-bottom: 20px;
}
.btn-default {
background-color: #73b626;
border-bottom-color: #73b626;
border-left-color: #73b626;
border-right-color: #73b626;
border-top-color: #73b626;
color: #ffffff;
}
.btn-default:hover {
background-color: #5e9b24;
border-bottom-color: #5e9b24;
border-left-color: #5e9b24;
border-right-color: #5e9b24;
border-top-color: #5e9b24;
color: #ffffff;
}
.form-control {
outline: 0;
border-color: #ECECEC;
border-style: solid;
border-width: 1px;
width: 100%;
background-color: #ffffff;
padding: 6px;
border-radius: 2px;
margin-bottom: 5px;
font-size: 14px;
}
.ace_editor {
min-height: 300px;
border: 1px solid #f0f0f0;
}
.authc-disabled {
color: #ccc;
}
/**
* Angular UI-Select
*/
// Since we have Bootstrap classes through the component,
// we need to remove some styles
.ui-select-container {
.ui-select-toggle.form-control {
padding-bottom: 0;
margin-bottom: 0;
}
}
// Helps mimic the other fields that we're using
.ui-select-search,
.ui-select-toggle {
padding-left: 12px;
border-radius: 4px;
}
// Neutralize default styles
.ui-select-match .btn:hover {
background-color: #fff;
color: #2D2D2D;
border: 1px solid #D9D9D9;
}

View File

@ -0,0 +1,18 @@
import { uiModules } from 'ui/modules';
import { get } from 'lodash';
import client from './backend_api/client';
import './directives/directives';
const app = uiModules.get('apps/opendistro_security/configuration', ['ui.ace']);
app.controller('securityConfigurationController', function ($scope, $element, $route, $window, $http, createNotifier, backendAPI) {
$scope.errorMessage = "";
$scope.title = "Security";
$scope.clearCache = function() {
backendAPI.clearCache();
}
});

View File

@ -0,0 +1,10 @@
<div>
<div class="kuiTogglePanelHeader">
<accordeon-header additional-class="{{accordeon.additionalClass}}" title="{{ accordeon.title }}" is-collapsed="accordeon.isCollapsed" on-click="accordeon.toggle">
</accordeon-header>
</div>
<div
ng-hide="accordeon.isCollapsed"
ng-transclude
></div>
</div>

View File

@ -0,0 +1,28 @@
import { uiModules } from 'ui/modules';
import template from './accordeon.html';
import './accordeon_header';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('accordeon', function () {
return {
restrict: 'E',
replace: true,
transclude: true,
template: template,
scope: {
accordeonId: '@',
title: '@',
isCollapsed: '=',
additionalClass: '@'
},
controllerAs: 'accordeon',
bindToController: true,
controller: class AccordeonController {
toggle = () => {
this.isCollapsed = !this.isCollapsed;
//this.onToggle(this.togglePanelId);
};
}
};
});

View File

@ -0,0 +1,6 @@
<button type="button" class="kuiToggleButton" ng-click="accordeonHeader.onClick()">
<h5>
<span style="margin-right:10px;" ng-class="{'kuiIcon fa-caret-down': !accordeonHeader.isCollapsed, 'kuiIcon fa-caret-right': accordeonHeader.isCollapsed}" class="fa {{accordeonHeader.additionalClass}}"></span>
<span class="{{accordeonHeader.additionalClass}}">{{ accordeonHeader.title }}</span>
</h5>
</button>

View File

@ -0,0 +1,22 @@
import { uiModules } from 'ui/modules';
import template from './accordeon_header.html';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('accordeonHeader', function () {
return {
restrict: 'E',
replace: true,
template: template,
scope: {
title: '@',
isCollapsed: '=',
onClick: '=',
additionalClass: '@'
},
controllerAs: 'accordeonHeader',
bindToController: true,
controller: class ToggleButtonController {
}
};
});

View File

@ -0,0 +1,19 @@
<div class="angucomplete-holder">
<input id="{{id}}_value" ng-required="{{isrequired}}" ng-model="selectedObject" type="text" placeholder="{{placeholder}}" class="{{inputClass}}" pattern="{{pattern}}" onmouseup="this.select();" ng-focus="resetHideResults()" ng-blur="hideResults()" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
<div id="{{id}}_dropdown" class="angucomplete-dropdown" ng-if="showDropdown">
<div class="angucomplete-searching" ng-show="searching">Searching...</div>
<div class="angucomplete-searching" ng-show="!searching && (!results || results.length == 0)">No results found
</div>
<div class="angucomplete-row" ng-repeat="result in results" ng-mousedown="selectResult(result)" ng-mouseover="hoverRow()" ng-class="{'angucomplete-selected-row': $index == currentIndex}">
<div ng-if="imageField" class="angucomplete-image-holder">
<img ng-if="result.image && result.image != ''" ng-src="{{result.image}}" class="angucomplete-image"/>
<div ng-if="!result.image && result.image != ''" class="angucomplete-image-default"></div>
</div>
<div class="angucomplete-title" ng-if="matchClass" ng-bind-html="result.title"></div>
<div class="angucomplete-title" ng-if="!matchClass">{{ result.title }}</div>
<div ng-if="result.description && result.description != ''" class="angucomplete-description">
{{result.description}}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,261 @@
/**
* Angucomplete
* Autocomplete directive for AngularJS
* By Daryl Rowland
*/
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('angucomplete', function ($parse, $http, $sce, $timeout) {
return {
restrict: 'EA',
scope: {
"id": "@id",
"placeholder": "@placeholder",
"selectedObject": "=selectedobject",
"modelArray": "=modelarray",
"url": "@url",
"dataField": "@datafield",
"titleField": "@titlefield",
"descriptionField": "@descriptionfield",
"imageField": "@imagefield",
"imageUri": "@imageuri",
"inputClass": "@inputclass",
"userPause": "@pause",
"localData": "=localdata",
"searchFields": "@searchfields",
"minLengthUser": "@minlength",
"matchClass": "@matchclass",
"isrequired": "@isrequired"
},
template: require('./angucomplete.html'),
link: function($scope, elem, attrs) {
$scope.lastSearchTerm = null;
$scope.currentIndex = null;
$scope.justChanged = false;
$scope.searchTimer = null;
$scope.hideTimer = null;
$scope.searching = false;
$scope.pause = 500;
$scope.minLength = 3;
$scope.searchStr = $scope.selectedObject;
if ($scope.minLengthUser && $scope.minLengthUser != "") {
$scope.minLength = $scope.minLengthUser;
}
if ($scope.userPause) {
$scope.pause = $scope.userPause;
}
$scope.remove = function () {
if(!Array.isArray($scope.modelArray)) {
return;
}
if (confirm(`Are you sure you want to delete '${$scope.selectedObject}'?`)) {
var index = $scope.modelArray.indexOf($scope.selectedObject);
$scope.modelArray.splice(index, 1);
}
}
$scope.isNewSearchNeeded = function(newTerm, oldTerm) {
return newTerm.length >= $scope.minLength && newTerm != oldTerm
}
$scope.processResults = function(responseData, str) {
if (responseData && responseData.length > 0) {
$scope.results = [];
var titleFields = [];
if ($scope.titleField && $scope.titleField != "") {
titleFields = $scope.titleField.split(",");
}
for (var i = 0; i < responseData.length; i++) {
// Get title variables
var titleCode = [];
for (var t = 0; t < titleFields.length; t++) {
titleCode.push(responseData[i][titleFields[t]]);
}
var description = "";
if ($scope.descriptionField) {
description = responseData[i][$scope.descriptionField];
}
var imageUri = "";
if ($scope.imageUri) {
imageUri = $scope.imageUri;
}
var image = "";
if ($scope.imageField) {
image = imageUri + responseData[i][$scope.imageField];
}
var text = titleCode.join(' ');
if ($scope.matchClass) {
var re = new RegExp(str, 'i');
var strPart = text.match(re)[0];
text = $sce.trustAsHtml(text.replace(re, '<span class="'+ $scope.matchClass +'">'+ strPart +'</span>'));
}
var resultRow = {
title: text,
description: description,
image: image,
originalObject: responseData[i]
}
$scope.results[$scope.results.length] = resultRow;
}
} else {
$scope.results = [];
}
}
$scope.searchTimerComplete = function(str) {
// Begin the search
if (str.length >= $scope.minLength) {
if ($scope.localData) {
var searchFields = $scope.searchFields.split(",");
var matches = [];
for (var i = 0; i < $scope.localData.length; i++) {
var match = false;
for (var s = 0; s < searchFields.length; s++) {
match = match || (typeof $scope.localData[i][searchFields[s]] === 'string' && typeof str === 'string' && $scope.localData[i][searchFields[s]].toLowerCase().indexOf(str.toLowerCase()) >= 0);
}
if (match) {
matches[matches.length] = $scope.localData[i];
}
}
$scope.searching = false;
$scope.processResults(matches, str);
} else {
$http.get($scope.url + str, {}).
success(function(responseData, status, headers, config) {
$scope.searching = false;
$scope.processResults((($scope.dataField) ? responseData[$scope.dataField] : responseData ), str);
}).
error(function(data, status, headers, config) {
});
}
}
}
$scope.hideResults = function() {
$scope.hideTimer = $timeout(function() {
$scope.showDropdown = false;
}, $scope.pause);
};
$scope.resetHideResults = function() {
if($scope.hideTimer) {
$timeout.cancel($scope.hideTimer);
};
};
$scope.hoverRow = function(index) {
$scope.currentIndex = index;
}
$scope.keyPressed = function(event) {
if (!(event.which == 38 || event.which == 40 || event.which == 13)) {
if (!$scope.selectedObject || $scope.selectedObject == "") {
$scope.showDropdown = false;
$scope.lastSearchTerm = null
} else if ($scope.isNewSearchNeeded($scope.selectedObject, $scope.lastSearchTerm)) {
$scope.lastSearchTerm = $scope.selectedObject
$scope.showDropdown = true;
$scope.currentIndex = -1;
$scope.results = [];
if ($scope.searchTimer) {
$timeout.cancel($scope.searchTimer);
}
$scope.searching = true;
$scope.searchTimer = $timeout(function() {
$scope.searchTimerComplete($scope.selectedObject);
}, $scope.pause);
}
} else {
event.preventDefault();
}
}
$scope.selectResult = function(result) {
if ($scope.matchClass) {
result.title = result.title.toString().replace(/(<([^>]+)>)/ig, '');
}
$scope.selectedObject = $scope.lastSearchTerm = result.title;
$scope.selectedObject = result.originalObject[$scope.dataField];
$scope.showDropdown = false;
$scope.results = [];
//$scope.$apply();
}
var inputField = elem.find('input');
inputField.on('keyup', $scope.keyPressed);
elem.on("keyup", function (event) {
if(event.which === 40) {
if ($scope.results && ($scope.currentIndex + 1) < $scope.results.length) {
$scope.currentIndex ++;
$scope.$apply();
event.preventDefault;
event.stopPropagation();
}
$scope.$apply();
} else if(event.which == 38) {
if ($scope.currentIndex >= 1) {
$scope.currentIndex --;
$scope.$apply();
event.preventDefault;
event.stopPropagation();
}
} else if (event.which == 13) {
if ($scope.results && $scope.currentIndex >= 0 && $scope.currentIndex < $scope.results.length) {
$scope.selectResult($scope.results[$scope.currentIndex]);
$scope.$apply();
event.preventDefault;
event.stopPropagation();
} else {
$scope.results = [];
$scope.$apply();
event.preventDefault;
event.stopPropagation();
}
} else if (event.which == 27) {
$scope.results = [];
$scope.showDropdown = false;
$scope.$apply();
} else if (event.which == 8) {
$scope.selectedObject = "";
$scope.$apply();
}
});
}
};
});

View File

@ -0,0 +1,19 @@
<div class="kuiModalOverlay js-confirmOverlay" ng-click="vm.closeOnOverlay($event)">
<div class="kuiModal" style="width: 450px;">
<div class="kuiModalHeader">
<div class="kuiModalHeader__title">
{{::vm.header}}
</div>
</div>
<div class="kuiModalBody">
<div class="kuiModalBodyText">
{{::vm.body}}
</div>
</div>
<div class="kuiModalFooter">
<button type="button" ng-click="vm.closeModal('cancel')" class="kuiButton {{::vm.config.buttons.cancel.classes}}"><span
class="kuiButton__inner"><span>Cancel</span></span></button>
<button type="button" ng-click="vm.confirm()" class="kuiButton {{::vm.config.buttons.confirm.classes}}" id="opendistro_security.button.deletemodal.confirm"><span>Confirm</span></span></button>
</div>
</div>
</div>

View File

@ -0,0 +1,133 @@
import { uiModules } from 'ui/modules';
//import { merge } from 'lodash';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('securitycConfirmationModal', function () {
return {
template: require('./confirmationmodal.html'),
restrict: 'E',
scope: {},
controllerAs: 'vm',
bindToController: {
/**
* Dialog title
* @param {string}
*/
header: '@',
/**
* Dialog body text
* @param {string}
*/
body: '@',
/**
* Optional params object that would be passed
* to the on-confirm function
* @param {object}
*/
params: '=',
/**
* Confirm handler
* @param {function}
*/
onConfirm: '&',
/**
* Close handler
* @param {function}
*/
onClose: '&',
/**
* Optional object that extends the default config
* @todo See if the angular version supports one way binding
*/
extendConfig: '=',
},
controller: function($scope, $timeout) {
// Default config
// @todo Annoying to override?
this.config = {
buttons: {
confirm: {
label: 'Confirm',
classes: 'kuiButton--primary'
},
cancel: {
label: 'Cancel',
classes: 'kuiButton--hollow'
}
}
};
this.$onInit = function () {
if (this.extendConfig) {
this.config = angular.merge({}, this.config, this.extendConfig);
}
// We want to be able to close the modal with the escape key
document.addEventListener('keydown', handleCloseOnEsc);
};
/**
* User confirmed the action
*/
this.confirm = function () {
if (this.onConfirm()) {
this.onConfirm()({
params: this.params
});
}
};
/**
* Cancel the confirmation.
* If available, a "reason" is passed to the handler.
* Reasons:
* - cancel (user clicked the cancel button)
* - overlay (user clicked the overlay)
* - esc (user hit the escape key)
* @param {String} reason
*/
this.closeModal = function (reason) {
if (this.onClose()) {
this.onClose()(reason);
}
};
/**
* Handle keydown events and close the modal with the escape key
* @param {Event} event
*/
let handleCloseOnEsc = (event) => {
if (event.keyCode === 27) {
// Make sure angular is notified
$timeout(() => {
this.closeModal('esc');
});
}
};
/**
* Handle clicks on the overlay and check whether to close the modal
* @param {Event} event
*/
this.closeOnOverlay = function (event) {
// Make sure we only close on clicks that happened
// on the overlay itself, and not on its content
if (!angular.element(event.target).hasClass('js-confirmOverlay')) {
return;
}
this.closeModal('overlay');
};
/**
* Clean up
*/
this.$onDestroy = function () {
document.removeEventListener('keydown', handleCloseOnEsc);
};
}
};
});

View File

@ -0,0 +1,16 @@
<div class="kuiModalOverlay" ng-show="displayModal">
<div class="kuiModal" style="width: 450px;">
<div class="kuiModalHeader">
<div class="kuiModalHeader__title">Confirm Delete</div>
</div>
<div class="kuiModalBody">
<div class="kuiModalBodyText">Do you really want to delete '{{deleteModalResourceName}}'?
</div>
</div>
<div class="kuiModalFooter">
<button type="button" ng-click="closeDeleteModal()" class="kuiButton kuiButton--hollow"><span
class="kuiButton__inner"><span>Cancel</span></span></button>
<button type="button" ng-click="delete()" class="kuiButton kuiButton--primary"><span>Confirm</span></span></button>
</div>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('securitycDeleteModal', function () {
return {
template: require('./deletemodal.html'),
restrict: 'E',
scope: false
};
});

View File

@ -0,0 +1,13 @@
import './edit_header/edit_header';
import './filterbar/filterbar';
import './form_resourcename/form_resourcename';
import './form_newresourcefield/form_newresourcefield';
import './list_header/list_header';
import './accordeon/accordeon';
import './permissions/permissions';
import './fileupload/fileupload';
import './header/header';
import './errormessage/errormessage';
import './confirmationmodal/confirmationmodal';
import './form_focusfield/form_focusaddedfield';
import './form_focusfield/form_focusfield';

View File

@ -0,0 +1,9 @@
<div class="kuiLocalNav">
<div class="kuiLocalNavRow">
<div class="kuiLocalNavRow__section">
<div class="kuiLocalTitle">
{{title()}}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('securitycEditHeader', function () {
return {
template: require('./edit_header.html'),
replace: true,
restrict: 'E'
};
});

View File

@ -0,0 +1,6 @@
<div class="kuiInfoPanel kuiInfoPanel--error" ng-show="errorMessage" style="margin-bottom:10px;">
<div class="kuiInfoPanelHeader">
<span class="kuiInfoPanelHeader__icon kuiIcon kuiIcon--error fa-warning"></span>
<span class="kuiInfoPanelHeader__title" ng-bind-html="errorMessage" id="opendistro_security.label.form.errormessage"></span>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('securitycErrorMessage', function () {
return {
template: require('./errormessage.html'),
replace: true,
restrict: 'E'
};
});

View File

@ -0,0 +1,26 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('fileModel', ['$parse', function ($parse) {
return {
restrict: 'A',
link: function(scope, element, attrs) {
//var fileReader = new $window.FileReader();
var reader = new FileReader();
var model = $parse(attrs.fileModel);
var modelSetter = model.assign;
element.bind('change', function(){
scope.$apply(function(){
var file = element[0].files[0];
reader.readAsText(file);
reader.onload = function (evt) {
modelSetter(scope, reader.result);
}
});
});
}
};
}]);

View File

@ -0,0 +1,51 @@
<div>
<div class="kuiToolBar">
<div class="kuiToolBarSearch">
<div class="kuiToolBarSearchBox">
<div class="kuiToolBarSearchBox__icon kuiIcon fa-search"></div>
<input class="kuiToolBarSearchBox__input ng-pristine ng-untouched ng-valid" type="text" placeholder="Search..." ng-model="query">
</div>
</div>
<div class="kuiToolBarSection">
<button id="opendistro_security.button.toolbar.new" type="button" tooltip="Add a new {{service.title.singular}}" ng-click="new(query)" class="kuiButton kuiButton--primary kuiButton--iconText">
<span class="kuiButton__icon kuiIcon fa-plus"></span>
</button>
<a ng-href="#/" class="kuiButton kuiButton--basic kuiButton--iconText" tooltip="Back to overview">
<span class="kuiButton__icon kuiIcon fa-chevron-left" id="opendistro_security.button.toolbar.back"></span>
Back
</a>
</div>
<div class="kuiToolBarSection">
<!-- We need an empty section for the buttons to be positioned consistently. -->
</div>
</div>
<!-- No Results -->
<div class="kuiPanel kuiPanel--centered kuiPanel--withToolBar" ng-show="!(resourcenames | filter:query).length && loaded">
<div class="kuiEmptyTablePrompt">
<div class="kuiEmptyTablePrompt__message">No {{service.title.plural}} found. Would you like to add one?</div>
<div class="kuiEmptyTablePrompt__actions">
<div class="kuiButtonGroup">
<button id="opendistro_security.button.tableprompt.new" type="button" ng-click="new(query)" class="kuiButton kuiButton--primary kuiButton--iconText" aria-label="Add a new {{service.title.singular}}">
<span class="kuiButton__inner">
<span aria-hidden="true" class="kuiButton__icon kuiIcon fa-plus"></span>
<span>Add a new {{service.title.singular}}</span>
</span>
</button>
</div>
</div>
</div>
</div>
<!-- Loading -->
<div class="kuiPanel kuiPanel--centered kuiPanel--withToolBar" ng-show="!loaded">
<div class="kuiEmptyTablePrompt">
<div class="kuiEmptyTablePrompt__message">Loading ...</div>
</div>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('securitycFilterBar', function () {
return {
template: require('./filterbar.html'),
replace: true,
restrict: 'E'
};
});

View File

@ -0,0 +1,74 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
/**
* Attaches a mutation observer and checks for
* dynamically added input type="text" elements
*/
app.directive('securitycFormFocusAddedField', function ($timeout) {
return {
restrict: 'A',
link: function(scope, el) {
/**
* The MutationObserver
* @type {null|MutationObserver}
*/
let observer = null;
/**
* Options for the MutationObserver
* @type {{childList: boolean, subtree: boolean}}
*/
let config = {
childList: true,
subtree: true,
};
// Callback function to execute when mutations are observed
let observerCallback = function(mutationsList) {
// We only want mutations with added nodes
let mutationsWithAdditions = mutationsList.filter((mutation) => {
return (mutation.addedNodes.length);
});
if (mutationsWithAdditions.length === 0) {
return;
}
// For now, only bother with the first mutation
let mutation = mutationsWithAdditions[0];
// Check the first added node and make sure it's an ELEMENT_NODE (nodeType === 1)
if (mutation.addedNodes[0].nodeType === 1) {
let focusable = mutation.addedNodes[0].querySelector("input[type='text']");
// Only focus on elements without a value for now
if (focusable && focusable.value === '') {
focusable.focus();
}
}
};
if ('MutationObserver' in window) {
// Use a $timeout to avoid listening for mutations on the first render
$timeout(function() {
observer = new MutationObserver(observerCallback);
observer.observe(el[0], config);
});
}
/**
* Clean up
*/
el.on('$destroy', function() {
if (observer !== null) {
observer.disconnect();
}
});
}
};
});

View File

@ -0,0 +1,40 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
/**
* Adds focus to a single input field.
* Since we may use this in conjunction with UI-Select, we support
* adding the directive both directly to an input field, but also
* to a container, in which the element can be found.
*/
app.directive('securitycFormFocusField', function ($timeout) {
return {
restrict: 'A',
scope: {
focusWhen: '=',
},
link: function(scope, el) {
scope.$watch('focusWhen', function(current, previous) {
if (current === true && ! previous) {
$timeout(function() {
// We use several UI-Select instances, so in order to support those we
// can also attach this directive to a parent container
if (el[0].nodeName.toLowerCase() !== 'input') {
let focusable = el[0].querySelector("input[type='text']");
if (focusable) {
focusable.focus();
}
} else {
// Directive seems to be attached to an input element
el[0].focus();
}
});
}
});
}
};
});

View File

@ -0,0 +1,14 @@
<div>
<div ng-show="isNew" style="margin-bottom:20px;">
<h3 style="margin-top:0px;">{{service.newLabel}}:</h3>
<input
id="object-form-id-new"
name="objectId"
type="text"
ng-model="resourcename"
class="form-control kuiTextInput"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
required
ng-required="isNew"/>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('securitycFormNewResourceField', function () {
return {
template: require('./form_newresourcefield.html'),
replace: true,
restrict: 'E'
};
});

View File

@ -0,0 +1,37 @@
<div>
<table class="fullwidth" ng-show="!isNew" style="margin-bottom:10px;">
<tr>
<td>
<h3 class="resourcename" id="opendistro_security.label.resource.resourcename">
{{resourcelabel}}: {{resourcename}}
<div class="kuiBadge kuiBadge--default" ng-if="resource.readonly">
<span class="kuiIcon fa-lock" id="opendistro_security.label.resource.reserved"></span>
reserved
</div>
</h3>
</td>
<td class="actions">
<button id="opendistro_security.button.resource.json.refresh" ng-if="showEditor" type="button" class="kuiButton kuiButton--secondary kuiButton--iconText" ng-click="loadJSON(resource)">
<span class="kuiButton__icon kuiIcon fa-refresh" ng-bind></span>
Refresh
</button>
<button id="opendistro_security.button.resource.json.toggle" type="button" class="kuiButton kuiButton--secondary kuiButton--iconText" ng-click="toggleEditor(resource)">
<span class="kuiButton__icon kuiIcon fa-code" ng-bind></span>
{{toggleEditorLabel}}
</button>
</td>
</tr>
</table>
<div style="margin-bottom:10px;"
id="object-form-json-raw"
name="jsonRaw"
ui-ace="{ onLoad: aceLoaded, mode: 'json' }"
readonly
ng-model="resourceAsJson"
required
ng-if="showEditor"
>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('securitycFormResourceName', function () {
return {
template: require('./form_resourcename.html'),
replace: true,
restrict: 'E'
};
});

View File

@ -0,0 +1,9 @@
<div class="kuiLocalNav kuiLocalNavMain">
<div class="kuiLocalNavRow">
<div class="kuiLocalNavRow__section">
<div class="headerHeadline" id="opendistro_security.label.header.title">
{{title}}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('securitycHeader', function () {
return {
template: require('./header.html'),
replace: true,
restrict: 'E'
};
});

View File

@ -0,0 +1,10 @@
<div class="kuiLocalNav">
<div class="kuiLocalNavRow">
<div class="kuiLocalNavRow__section">
<div class="headerHeadline">
<span id="opendistro_security.label.header.title">{{title}}</span><br/>
<span id="opendistro_security.label.header.numresources">{{numresources}} entries found.</span>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('securitycListHeader', function () {
return {
template: require('./list_header.html'),
replace: true,
restrict: 'E'
};
});

View File

@ -0,0 +1,115 @@
<div>
<table class="kuiTable tableIndexGroups">
<thead>
<tr>
<th class="kuiTableHeaderCell tableHeaderCellIndexGroups">
<span class="kuiTableHeaderCell__liner">
Permissions: Action Groups
</span>
</th>
<th class="kuiTableHeaderCell tableHeaderCellIndexGroups actions">
<span class="kuiTableHeaderCell__liner">
<input type="checkbox" data-ng-model="showAdvanced"> Show Advanced</td>
</span>
</th>
</tr>
</thead>
<tbody>
<tr class="kuiTableRow" ng-show="!permissionsResource.actiongroups.length">
<td colspan="2" class="kuiTableRowCell">
<div class="kuiPanel kuiPanel--centered kuiPanel--noborder">
<div class="kuiEmptyTablePrompt kuiEmptyTablePrompt--nopadding">
<div class="kuiEmptyTablePrompt__message ng-binding">No Action Groups found.</div>
</div>
</div>
</td>
</tr>
<tr class="kuiTableRow" data-ng-repeat="actiongroup in permissionsResource.actiongroups track by $index" >
<td class="kuiTableRowCell cellAlignTop">
<fieldset class="marginbottom--small" id="object-form-actiongroups">
<ui-select ng-model="permissionsResource.actiongroups[$index]">
<ui-select-match placeholder="Action Group name">
{{permissionsResource.actiongroups[$index]}}
</ui-select-match>
<ui-select-choices repeat="item in (getActiongroupItems() | filter: $select.search) track by $index">
<span ng-bind="item"></span>
</ui-select-choices>
</ui-select>
</fieldset>
</td>
<td class="kuiTableRowCell cellAlignTop actions">
<button style="display:inline-block" type="button" ng-click="confirmDeletePermission(permissionsResource.actiongroups, actiongroup)" class="kuiButton kuiButton--danger kuiButton--iconText">
<span class="kuiButton__icon kuiIcon fa-trash-o"></span>
</button>
</td>
</tr>
<tr class="kuiTableRow">
<td class="kuiTableRowCell cellAlignTop ">
</td>
<td class="kuiTableRowCell cellAlignTop actions">
<button type="button" ng-click="addArrayEntry(permissionsResource, 'actiongroups', '')" ng-disabled="lastArrayEntryEmpty(permissionsResource.actiongroups)" class="kuiButton kuiButton--primary kuiButton--iconText">
<span class="kuiButton__icon kuiIcon fa-plus"></span>
Add Action Group
</button>
</td>
</tr>
</tbody>
</table>
<!-- Permissions Table -->
<table class="kuiTable tableIndexGroups" ng-if="showAdvanced">
<thead>
<tr>
<th class="kuiTableHeaderCell tableHeaderCellIndexGroups">
<span class="kuiTableHeaderCell__liner">
Permissions: Single Permissions
</span>
</th>
<th class="kuiTableHeaderCell">
</th>
</tr>
</thead>
<tbody>
<tr class="kuiTableRow" ng-show="!permissionsResource.permissions.length">
<td colspan="2" class="kuiTableRowCell">
<div class="kuiPanel kuiPanel--centered kuiPanel--noborder">
<div class="kuiEmptyTablePrompt kuiEmptyTablePrompt--nopadding">
<div class="kuiEmptyTablePrompt__message ng-binding">No Single Permissions found.</div>
</div>
</div>
</td>
</tr>
<tr class="kuiTableRow" data-ng-repeat="permission in permissionsResource.permissions track by $index">
<td class="kuiTableRowCell cellAlignTop">
<fieldset class="marginbottom--small" id="object-form-actiongroups">
<ui-select ng-model="permissionsResource.permissions[$index]">
<ui-select-match placeholder="Start with cluster: or indices:">
{{permission}}
</ui-select-match>
<ui-select-choices repeat="item in (permissionItems | filter: $select.search) track by $index">
<span ng-bind="item"></span>
</ui-select-choices>
</ui-select>
</fieldset>
</td>
<td class="kuiTableRowCell cellAlignTop actions">
<a ng-click="confirmDeletePermission(permissionsResource.permissions, permission)" class="kuiButton kuiButton--danger kuiButton--iconText">
<span class="kuiButton__icon kuiIcon fa-trash-o"></span>
</a>
</td>
</tr>
<tr class="kuiTableRow">
<td class="kuiTableRowCell cellAlignTop actions"></td>
<td class="kuiTableRowCell cellAlignTop actions">
<button type="button" ng-click="addArrayEntry(permissionsResource, 'permissions', '')" ng-disabled="lastArrayEntryEmpty(permissionsResource.permissions)" class="kuiButton kuiButton--primary kuiButton--iconText">
<span class="kuiButton__icon kuiIcon fa-plus"></span>
Add Single Permission
</button>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,95 @@
import { uiModules } from 'ui/modules';
const app = uiModules.get('apps/opendistro_security/configuration', []);
app.directive('securitycPermissions', function () {
return {
template: require('./permissions.html'),
restrict: 'EA',
scope: {
"permissionsResource": "=permissionsresource",
'onShouldConfirm': '&',
},
controller: 'securityBaseController',
link: function(scope, elem, attr) {
scope.showAdvanced = null;
scope.actiongroupItems = [];
scope.$watch('permissionsResource', function(newValue, oldValue){
if(newValue && (scope.showAdvanced == null)) {
if (scope.permissionsResource.permissions && scope.permissionsResource.permissions.length > 0) {
scope.showAdvanced = true;
}
}
}, true)
/**
* Prepare values for the actiongroupsAutoComplete
* We could probably change the data source to avoid
* having to convert the data twice
* @returns {Array|*}
*/
scope.getActiongroupItems = function() {
if (scope.actiongroupItems.length) {
return scope.actiongroupItems;
}
if (scope.actiongroupsAutoComplete) {
scope.actiongroupItems = scope.actiongroupsAutoComplete.map((item) => {
return item.name;
});
}
return scope.actiongroupItems;
};
// UI-Select seems to work best with a plain array in this case
scope.permissionItems = scope.allpermissionsAutoComplete.map((item) => {
return item.name;
});
/**
* This is a weird workaround for the autocomplete where
* we have can't or don't want to use the model item
* directly in the view. Instead, we use the on-select
* event to set the target value
* @type {{}}
*/
/*
scope.onSelectedActionGroup = function(event) {
scope.permissionsResource.actiongroups[event.index] = event.item.name;
};
*/
/**
* This is a weird workaround for the autocomplete where
* we have can't or don't want to use the model item
* directly in the view. Instead, we use the on-select
* event to set the target value
* @type {{}}
*/
/*
scope.onSelectedPermission = function(event) {
scope.permissionsResource.permissions[event.index] = event.item.name;
};
*/
/**
* Since we have an isolated scope, we can't modify the parent scope without breaking
* the binding. Hence, we pass the parent scope's handler to this directive.
*
* An alternative could be to encapsulate the delete logic in a service.
*
* @param {array} source
* @param {string} item
*/
scope.confirmDeletePermission = function(source, item) {
scope.onShouldConfirm()(source, item);
};
}
}
});

View File

@ -0,0 +1,40 @@
export default [
{"name": "cluster:admin/ingest/pipeline/delete"},
{"name": "cluster:admin/ingest/pipeline/get"},
{"name": "cluster:admin/ingest/pipeline/put"},
{"name": "cluster:admin/ingest/pipeline/simulate"},
{"name": "cluster:admin/ingest/processor/grok/get"},
{"name": "cluster:admin/reindex/rethrottle"},
{"name": "cluster:admin/repository/delete"},
{"name": "cluster:admin/repository/get"},
{"name": "cluster:admin/repository/put"},
{"name": "cluster:admin/repository/verify"},
{"name": "cluster:admin/reroute"},
{"name": "cluster:admin/script/delete"},
{"name": "cluster:admin/script/get"},
{"name": "cluster:admin/script/put"},
{"name": "cluster:admin/settings/update"},
{"name": "cluster:admin/snapshot/create"},
{"name": "cluster:admin/snapshot/delete"},
{"name": "cluster:admin/snapshot/get"},
{"name": "cluster:admin/snapshot/restore"},
{"name": "cluster:admin/snapshot/status"},
{"name": "cluster:admin/snapshot/status*"},
{"name": "cluster:admin/tasks/cancel"},
{"name": "cluster:admin/tasks/test"},
{"name": "cluster:admin/tasks/testunblock"},
{"name": "cluster:monitor/allocation/explain"},
{"name": "cluster:monitor/health"},
{"name": "cluster:monitor/main"},
{"name": "cluster:monitor/nodes/hot_threads"},
{"name": "cluster:monitor/nodes/info"},
{"name": "cluster:monitor/nodes/liveness"},
{"name": "cluster:monitor/nodes/stats"},
{"name": "cluster:monitor/nodes/usage"},
{"name": "cluster:monitor/remote/info"},
{"name": "cluster:monitor/state"},
{"name": "cluster:monitor/stats"},
{"name": "cluster:monitor/task"},
{"name": "cluster:monitor/task/get"},
{"name": "cluster:monitor/tasks/list"}
];

View File

@ -0,0 +1,64 @@
export default [
{"name": "indices:admin/aliases"},
{"name": "indices:admin/aliases/exists"},
{"name": "indices:admin/aliases/get"},
{"name": "indices:admin/analyze"},
{"name": "indices:admin/cache/clear"},
{"name": "indices:admin/close"},
{"name": "indices:admin/create"},
{"name": "indices:admin/delete"},
{"name": "indices:admin/exists"},
{"name": "indices:admin/flush"},
{"name": "indices:admin/flush*"},
{"name": "indices:admin/forcemerge"},
{"name": "indices:admin/get"},
{"name": "indices:admin/mapping/put"},
{"name": "indices:admin/mappings/fields/get"},
{"name": "indices:admin/mappings/fields/get*"},
{"name": "indices:admin/mappings/get"},
{"name": "indices:admin/open"},
{"name": "indices:admin/refresh"},
{"name": "indices:admin/refresh*"},
{"name": "indices:admin/rollover"},
{"name": "indices:admin/seq_no/global_checkpoint_sync"},
{"name": "indices:admin/settings/update"},
{"name": "indices:admin/shards/search_shards"},
{"name": "indices:admin/shrink"},
{"name": "indices:admin/synced_flush"},
{"name": "indices:admin/template/delete"},
{"name": "indices:admin/template/get"},
{"name": "indices:admin/template/put"},
{"name": "indices:admin/types/exists"},
{"name": "indices:admin/upgrade"},
{"name": "indices:admin/validate/query"},
{"name": "indices:data/read/explain"},
{"name": "indices:data/read/field_caps"},
{"name": "indices:data/read/field_caps*"},
{"name": "indices:data/read/get"},
{"name": "indices:data/read/mget"},
{"name": "indices:data/read/mget*"},
{"name": "indices:data/read/msearch"},
{"name": "indices:data/read/msearch/template"},
{"name": "indices:data/read/mtv"},
{"name": "indices:data/read/mtv*"},
{"name": "indices:data/read/scroll"},
{"name": "indices:data/read/scroll/clear"},
{"name": "indices:data/read/search"},
{"name": "indices:data/read/search*"},
{"name": "indices:data/read/search/template"},
{"name": "indices:data/read/tv"},
{"name": "indices:data/write/bulk"},
{"name": "indices:data/write/bulk*"},
{"name": "indices:data/write/delete"},
{"name": "indices:data/write/delete/byquery"},
{"name": "indices:data/write/index"},
{"name": "indices:data/write/reindex"},
{"name": "indices:data/write/update"},
{"name": "indices:data/write/update/byquery"},
{"name": "indices:monitor/recovery"},
{"name": "indices:monitor/segments"},
{"name": "indices:monitor/settings/get"},
{"name": "indices:monitor/shard_stores"},
{"name": "indices:monitor/stats"},
{"name": "indices:monitor/upgrade"}
]

Some files were not shown because too many files have changed in this diff Show More