Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d243802
Beginning to use the ES APIs to insert/check privileges
kobelb Apr 27, 2018
d9c93b7
Removing todo comment, I think we're good with the current check
kobelb Apr 27, 2018
5aaf5bc
Adding ability to edit kibana application privileges
kobelb Apr 30, 2018
05c594f
Introducing DEFAULT_RESOURCE constant
kobelb Apr 30, 2018
4d60ca1
Removing unused arguments when performing saved objects auth check
kobelb Apr 30, 2018
03c429a
Performing bulkCreate auth more efficiently
kobelb May 8, 2018
1d2b564
Throwing error in SavedObjectClient.find if type isn't provided
kobelb May 8, 2018
a9fda18
Fixing Reporting and removing errant console.log
kobelb May 8, 2018
80494b8
Introducing a separate hasPrivileges "service"
kobelb May 9, 2018
6567941
Adding tests and fleshing out the has privileges "service"
kobelb May 11, 2018
d93b758
Fixing error message
kobelb May 14, 2018
442ee73
You can now edit whatever roles you want
kobelb May 14, 2018
3019006
We're gonna throw the find error in another PR
kobelb May 14, 2018
5a16dea
Changing conflicting version detection to work when user has no
kobelb May 14, 2018
92ab282
Throwing correct error when user is forbidden
kobelb May 14, 2018
bb87576
Removing unused interceptor
kobelb May 14, 2018
6f83da2
Adding warning if they're editing a role with application privileges we
kobelb May 14, 2018
1ea7cb9
Fixing filter...
kobelb May 14, 2018
dddccb6
Beginning to only update privileges when they need to be
kobelb May 15, 2018
f0f705e
More tests
kobelb May 15, 2018
98c9d8c
One more test...
kobelb May 15, 2018
19dd2bc
Restricting the rbac application name that can be chosen
kobelb May 15, 2018
c35e759
Removing DEFAULT_RESOURCE check
kobelb May 15, 2018
4385765
Supporting 1024 characters for the role name
kobelb May 15, 2018
e621440
Renaming some variables, fixing issue with role w/ no kibana privileges
kobelb May 15, 2018
4096be9
Throwing decorated general error when appropriate
kobelb May 15, 2018
22a22ac
Fixing test description
kobelb May 16, 2018
9d73c4b
Dedent does nothing...
kobelb May 16, 2018
ab2d9ff
Renaming some functions
kobelb May 16, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ function executeJobFn(server) {
return callWithRequest(fakeRequest, endpoint, clientParams, options);
};
const savedObjectsClient = server.savedObjectsClientFactory({
callCluster: callEndpoint
callCluster: callEndpoint,
request: fakeRequest
});
const uiSettings = server.uiSettingsServiceFactory({
savedObjectsClient
Expand Down
7 changes: 7 additions & 0 deletions x-pack/plugins/security/common/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

export const DEFAULT_RESOURCE = 'default';
16 changes: 12 additions & 4 deletions x-pack/plugins/security/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import { initAuthenticator } from './server/lib/authentication/authenticator';
import { mirrorStatusAndInitialize } from './server/lib/mirror_status_and_initialize';
import { secureSavedObjectsClientWrapper } from './server/lib/saved_objects_client/saved_objects_client_wrapper';
import { secureSavedObjectsClientOptionsBuilder } from './server/lib/saved_objects_client/secure_options_builder';
import { registerPrivilegesWithCluster } from './server/lib/privileges/privilege_action_registry';
import { registerPrivilegesWithCluster } from './server/lib/privileges';
import { createDefaultRoles } from './server/lib/authorization/create_default_roles';
import { initPrivilegesApi } from './server/routes/api/v1/privileges';
import { hasPrivilegesWithServer } from './server/lib/authorization/has_privileges';

export const security = (kibana) => new kibana.Plugin({
id: 'security',
Expand All @@ -45,7 +46,10 @@ export const security = (kibana) => new kibana.Plugin({
rbac: Joi.object({
enabled: Joi.boolean().default(false),
createDefaultRoles: Joi.boolean().default(true),
application: Joi.string().default('kibana'),
application: Joi.string().default('kibana').regex(
/[a-zA-Z0-9-_]+/,
`may contain alphanumeric characters (a-z, A-Z, 0-9), underscores and hyphens`
),
}).default(),
}).default();
},
Expand Down Expand Up @@ -75,7 +79,8 @@ export const security = (kibana) => new kibana.Plugin({
return {
secureCookies: config.get('xpack.security.secureCookies'),
sessionTimeout: config.get('xpack.security.sessionTimeout'),
rbacEnabled: config.get('xpack.security.rbac.enabled')
rbacEnabled: config.get('xpack.security.rbac.enabled'),
rbacApplication: config.get('xpack.security.rbac.application'),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we use the rbacApplication to derive the role name, what do you think about validating that this also conforms to the role naming rules?

Role names must be at least 1 and no more than 1024 characters. 
They can contain alphanumeric characters (a-z, A-Z, 0-9), spaces, punctuation, and printable symbols in the Basic Latin (ASCII) block.
Leading or trailing whitespace is not allowed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I chose a more restrictive pattern because it'd look inconsistent to accept punctuation and spaces and then append _rbac_user and _rbac_dashboard_only_user to the end.

};
}
},
Expand Down Expand Up @@ -107,8 +112,11 @@ export const security = (kibana) => new kibana.Plugin({
server.auth.strategy('session', 'login', 'required');

if (config.get('xpack.security.rbac.enabled')) {
const hasPrivilegesWithRequest = hasPrivilegesWithServer(server);
const savedObjectsClientProvider = server.getSavedObjectsClientProvider();
savedObjectsClientProvider.addClientOptionBuilder((options) => secureSavedObjectsClientOptionsBuilder(server, options));
savedObjectsClientProvider.addClientOptionBuilder(options =>
secureSavedObjectsClientOptionsBuilder(server, hasPrivilegesWithRequest, options)
);
savedObjectsClientProvider.addClientWrapper(secureSavedObjectsClientWrapper);
}

Expand Down
29 changes: 19 additions & 10 deletions x-pack/plugins/security/public/views/management/edit_role.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<kbn-management-app section="security" omit-breadcrumb-pages="['edit']">
<!-- This content gets injected below the localNav. -->
<div class="kuiViewContent kuiViewContent--constrainedWidth kuiViewContentItem">

<!-- Subheader -->
<div class="kuiBar kuiVerticalRhythm">

<div class="kuiBarSection">
<!-- Title -->
<h1 class="kuiTitle">
Expand Down Expand Up @@ -39,6 +41,18 @@ <h1 class="kuiTitle">
</div>
</div>

<div class="kuiBar kuiVerticalRhythm" ng-if="otherApplications.length > 0">
<div class="kuiInfoPanel kuiInfoPanel--warning">
<div class="kuiInfoPanelHeader">
<span class="kuiInfoPanelHeader__icon kuiIcon kuiIcon--warning fa-warning"></span>
<span class="kuiInfoPanelHeader__title">
This role contains application privileges for the {{ otherApplications.join(', ') }} application(s) that can't be edited.
If they are for other instances of Kibana, you must manage those privileges on that Kibana.
</span>
</div>
</div>
</div>

<!-- Form -->
<form name="form" novalidate class="kuiVerticalRhythm">
<!-- Name -->
Expand All @@ -56,7 +70,7 @@ <h1 class="kuiTitle">
ng-model="role.name"
required
pattern="[a-zA-Z_][a-zA-Z0-9_@\-\$\.]*"
maxlength="30"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like ES supports role names up to 1024 characters. Is there a reason we don't mirror that on the Kibana side?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a good one! I have an excuse but I won't bore you with it.

maxlength="1024"
data-test-subj="roleFormNameInput"
/>

Expand Down Expand Up @@ -102,20 +116,15 @@ <h1 class="kuiTitle">
Kibana Privileges
</label>

<div class="kuiInputNote kuiInputNote--warning" ng-if="role.hasUnsupportedCustomPrivileges">
Changes to this section are not supported: this role contains application privileges that do not belong to this instance of Kibana.
</div>

<div ng-repeat="privilege in kibanaPrivileges track by privilege.name">
<div ng-repeat="(key, value) in kibanaPrivileges">
<label>
<input
class="kuiCheckBox"
type="checkbox"
ng-checked="includesPermission(role, privilege)"
ng-click="togglePermission(role, privilege)"
ng-disabled="role.metadata._reserved || !isRoleEnabled(role) || role.hasUnsupportedCustomPrivileges"
ng-model="kibanaPrivileges[key]"
ng-disabled="role.metadata._reserved || !isRoleEnabled(role)"
/>
<span class="kuiOptionLabel">{{privilege.metadata.displayName}}</span>
<span class="kuiOptionLabel">{{key}}</span>
</label>
</div>
</div>
Expand Down
71 changes: 62 additions & 9 deletions x-pack/plugins/security/public/views/management/edit_role.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,57 @@ import { IndexPatternsProvider } from 'ui/index_patterns/index_patterns';
import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info';
import { checkLicenseError } from 'plugins/security/lib/check_license_error';
import { EDIT_ROLES_PATH, ROLES_PATH } from './management_urls';
import { DEFAULT_RESOURCE } from '../../../common/constants';

const getKibanaPrivileges = (kibanaApplicationPrivilege, role, application) => {
const kibanaPrivileges = kibanaApplicationPrivilege.reduce((acc, p) => {
acc[p.name] = false;
return acc;
}, {});

if (!role.applications || role.applications.length === 0) {
return kibanaPrivileges;
}

const applications = role.applications.filter(x => x.application === application);

const assigned = _.uniq(_.flatten(_.pluck(applications, 'privileges')));
assigned.forEach(a => {
kibanaPrivileges[a] = true;
});

return kibanaPrivileges;
};

const setApplicationPrivileges = (kibanaPrivileges, role, application) => {
if (!role.applications) {
role.applications = [];
}

// we first remove the matching application entries
role.applications = role.applications.filter(x => {
return x.application !== application;
});

const privileges = Object.keys(kibanaPrivileges).filter(key => kibanaPrivileges[key]);

// if we still have them, put the application entry back
if (privileges.length > 0) {
role.applications = [...role.applications, {
application,
privileges,
resources: [ DEFAULT_RESOURCE ]
}];
}
};

const getOtherApplications = (kibanaPrivileges, role, application) => {
if (!role.applications || role.applications.length === 0) {
return [];
}

return role.applications.map(x => x.application).filter(x => x !== application);
};

routes.when(`${EDIT_ROLES_PATH}/:name?`, {
template,
Expand Down Expand Up @@ -48,7 +99,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, {
applications: []
});
},
kibanaPrivileges(ApplicationPrivilege, kbnUrl, Promise, Private) {
kibanaApplicationPrivilege(ApplicationPrivilege, kbnUrl, Promise, Private) {
return ApplicationPrivilege.query().$promise
.catch(checkLicenseError(kbnUrl, Promise, Private));
},
Expand All @@ -64,7 +115,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, {
}
},
controllerAs: 'editRole',
controller($injector, $scope, rbacEnabled) {
controller($injector, $scope, rbacEnabled, rbacApplication) {
const $route = $injector.get('$route');
const kbnUrl = $injector.get('kbnUrl');
const shieldPrivileges = $injector.get('shieldPrivileges');
Expand All @@ -77,8 +128,13 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, {
$scope.users = $route.current.locals.users;
$scope.indexPatterns = $route.current.locals.indexPatterns;
$scope.privileges = shieldPrivileges;
$scope.kibanaPrivileges = $route.current.locals.kibanaPrivileges;

$scope.rbacEnabled = rbacEnabled;
const kibanaApplicationPrivilege = $route.current.locals.kibanaApplicationPrivilege;
const role = $route.current.locals.role;
$scope.kibanaPrivileges = getKibanaPrivileges(kibanaApplicationPrivilege, role, rbacApplication);
$scope.otherApplications = getOtherApplications(kibanaApplicationPrivilege, role, rbacApplication);

$scope.rolesHref = `#${ROLES_PATH}`;

this.isNewRole = $route.current.params.name == null;
Expand All @@ -103,6 +159,9 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, {
$scope.saveRole = (role) => {
role.indices = role.indices.filter((index) => index.names.length);
role.indices.forEach((index) => index.query || delete index.query);

setApplicationPrivileges($scope.kibanaPrivileges, role, rbacApplication);

return role.$save()
.then(() => toastNotifications.addSuccess('Updated role'))
.then($scope.goToRoleList)
Expand Down Expand Up @@ -156,12 +215,6 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, {
}
};

$scope.hasPermission = (role, permission) => {
// TODO(legrego): faking until ES is implemented
const rolePermissions = role.applications || [];
return rolePermissions.find(rolePermission => permission.name === rolePermission.name);
};

$scope.union = _.flow(_.union, _.compact);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`throws error if missing version privilege and has login privilege 1`] = `"Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user."`;
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
* Licensed under the Elastic License; you may not use this file except in compliance with the Elastic License. */

import { getClient } from '../../../../../server/lib/get_client_shield';
import { DEFAULT_RESOURCE } from '../../../common/constants';


const createRoleIfDoesntExist = async (callCluster, name) => {
const createRoleIfDoesntExist = async (callCluster, { name, application, privilege }) => {
try {
await callCluster('shield.getRole', { name });
} catch (err) {
Expand All @@ -23,7 +24,13 @@ const createRoleIfDoesntExist = async (callCluster, name) => {
body: {
cluster: [],
index: [],
// application: [ { "privileges": [ "kibana:all" ], "resources": [ "*" ] } ]
applications: [
{
application,
privileges: [ privilege ],
resources: [ DEFAULT_RESOURCE ]
}
]
}
});
}
Expand All @@ -40,8 +47,17 @@ export async function createDefaultRoles(server) {

const callCluster = getClient(server).callWithInternalUser;

const createKibanaUserRole = createRoleIfDoesntExist(callCluster, `${application}_rbac_user`);
const createKibanaDashboardOnlyRole = createRoleIfDoesntExist(callCluster, `${application}_rbac_dashboard_only_user`);
const createKibanaUserRole = createRoleIfDoesntExist(callCluster, {
name: `${application}_rbac_user`,
application,
privilege: 'all'
});

const createKibanaDashboardOnlyRole = createRoleIfDoesntExist(callCluster, {
name: `${application}_rbac_dashboard_only_user`,
application,
privilege: 'read'
});

await Promise.all([createKibanaUserRole, createKibanaDashboardOnlyRole]);
}
55 changes: 55 additions & 0 deletions x-pack/plugins/security/server/lib/authorization/has_privileges.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { getClient } from '../../../../../server/lib/get_client_shield';
import { DEFAULT_RESOURCE } from '../../../common/constants';
import { getVersionPrivilege, getLoginPrivilege } from '../privileges';

const getMissingPrivileges = (resource, application, privilegeCheck) => {
const privileges = privilegeCheck.application[application][resource];
return Object.keys(privileges).filter(key => privileges[key] === false);
};

export function hasPrivilegesWithServer(server) {
const callWithRequest = getClient(server).callWithRequest;

const config = server.config();
const kibanaVersion = config.get('pkg.version');
const application = config.get('xpack.security.rbac.application');

return function hasPrivilegesWithRequest(request) {
return async function hasPrivileges(privileges) {

const versionPrivilege = getVersionPrivilege(kibanaVersion);
const loginPrivilege = getLoginPrivilege();

const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to add exception handling here? If this call throws, then I think the error will get thrown out of the SavedObjectsClient uninspected, which condradicts its documentation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I'll make sure we're throwing the appropriate wrapped errors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this will be used outside of the context of the SavedObjectClient, I'm going to have the SavedObjectClient's usage wrap these errors instead of modifying it directly in the hasPrivileges service.

body: {
applications: [{
application,
resources: [DEFAULT_RESOURCE],
privileges: [versionPrivilege, loginPrivilege, ...privileges]
}]
}
});

const success = privilegeCheck.has_all_requested;
const missingPrivileges = getMissingPrivileges(DEFAULT_RESOURCE, application, privilegeCheck);

// We include the login privilege on all privileges, so the existence of it and not the version privilege
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could very well be YAGNI, but I'll ask anyway: Do we envision having service accounts in the future, for reporting or other background tasks? In other words, could we have accounts that have privileges within Kibana, but without the ability to login and have an interactive session?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't heard any discussion of service accounts in this form, and they'd all have to authenticate somehow (making the login action relevant). I'm having trouble envisioning a situation where we couldn't rely on the login privilege to be present.

// lets us know that we're running in an incorrect configuration. Without the login privilege check, we wouldn't
// know whether the user just wasn't authorized for this instance of Kibana in general
if (missingPrivileges.includes(versionPrivilege) && !missingPrivileges.includes(loginPrivilege)) {
throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the corrective action would be "Stop doing that", but are there any steps we should document for users who find themselves in this situation? ("no" is a perfectly acceptable answer 😄)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely something that we'll want to document. There isn't really a corrective action besides "don't do that" unfortunately.

}

return {
success,
missing: missingPrivileges
};
};
};
}
Loading