mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Security - Role Mappings UI (#53620)
* Initial role mappings UI * apply design edits * address PR feedback * fix type cast for number field * Update x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.tsx Co-Authored-By: Joe Portner <5295965+jportner@users.noreply.github.com> * Cleanup FTR configuration, and handle role mapping 404 errors properly * align naming of role mappings feature check * Apply suggestions from code review Co-Authored-By: Brandon Kobel <brandon.kobel@gmail.com> * add missing test assertions * inlining feature check logic * switch to using snapshot * use href instead of onClick * adding delete unit test * consolidate href building * unify page load error handling * simplify initial loading state * documenting unconditional catch blocks * use nodes.info instead of transport.request * Apply suggestions from code review Co-Authored-By: Brandon Kobel <brandon.kobel@gmail.com> * move model out of LP into NP * convert except_field_rule to except_any_rule * docs, take 1 * update gif Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> Co-authored-by: Brandon Kobel <brandon.kobel@gmail.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
b057f18d16
commit
e6e1373db2
116 changed files with 9179 additions and 16 deletions
|
@ -37,4 +37,5 @@ cause Kibana's authorization to behave unexpectedly.
|
|||
include::authorization/index.asciidoc[]
|
||||
include::authorization/kibana-privileges.asciidoc[]
|
||||
include::api-keys/index.asciidoc[]
|
||||
include::role-mappings/index.asciidoc[]
|
||||
include::rbac_tutorial.asciidoc[]
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 379 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.1 MiB |
BIN
docs/user/security/role-mappings/images/role-mappings-grid.png
Normal file
BIN
docs/user/security/role-mappings/images/role-mappings-grid.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 339 KiB |
51
docs/user/security/role-mappings/index.asciidoc
Normal file
51
docs/user/security/role-mappings/index.asciidoc
Normal file
|
@ -0,0 +1,51 @@
|
|||
[role="xpack"]
|
||||
[[role-mappings]]
|
||||
=== Role mappings
|
||||
|
||||
Role mappings allow you to describe which roles to assign to your users
|
||||
using a set of rules. Role mappings are required when authenticating via
|
||||
an external identity provider, such as Active Directory, Kerberos, PKI, OIDC,
|
||||
or SAML.
|
||||
|
||||
Role mappings have no effect for users inside the `native` or `file` realms.
|
||||
|
||||
To manage your role mappings, use *Management > Security > Role Mappings*.
|
||||
|
||||
With *Role mappings*, you can:
|
||||
|
||||
* View your configured role mappings
|
||||
* Create/Edit/Delete role mappings
|
||||
|
||||
[role="screenshot"]
|
||||
image:user/security/role-mappings/images/role-mappings-grid.png["Role mappings"]
|
||||
|
||||
|
||||
[float]
|
||||
=== Create a role mapping
|
||||
|
||||
To create a role mapping, navigate to *Management > Security > Role Mappings*, and click **Create role mapping**.
|
||||
Give your role mapping a unique name, and choose which roles you wish to assign to your users.
|
||||
If you need more flexibility, you can use {ref}/security-api-put-role-mapping.html#_role_templates[role templates] instead.
|
||||
|
||||
Next, define the rules describing which users should receive the roles you defined. Rules can optionally grouped and nested, allowing for sophisticated logic to suite complex requirements.
|
||||
View the {ref}/role-mapping-resources.html[role mapping resources for an overview of the allowed rule types].
|
||||
|
||||
|
||||
[float]
|
||||
=== Example
|
||||
|
||||
Let's create a `sales-users` role mapping, which assigns a `sales` role to users whose username
|
||||
starts with `sls_`, *or* belongs to the `executive` group.
|
||||
|
||||
First, we give the role mapping a name, and assign the `sales` role:
|
||||
|
||||
[role="screenshot"]
|
||||
image:user/security/role-mappings/images/role-mappings-create-step-1.png["Create role mapping, step 1"]
|
||||
|
||||
Next, we define the two rules, making sure to set the group to *Any are true*:
|
||||
|
||||
[role="screenshot"]
|
||||
image:user/security/role-mappings/images/role-mappings-create-step-2.gif["Create role mapping, step 2"]
|
||||
|
||||
Click *Save role mapping* once you're finished.
|
||||
|
66
test/common/services/security/role_mappings.ts
Normal file
66
test/common/services/security/role_mappings.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import util from 'util';
|
||||
import { ToolingLog } from '@kbn/dev-utils';
|
||||
|
||||
export class RoleMappings {
|
||||
private log: ToolingLog;
|
||||
private axios: AxiosInstance;
|
||||
|
||||
constructor(url: string, log: ToolingLog) {
|
||||
this.log = log;
|
||||
this.axios = axios.create({
|
||||
headers: { 'kbn-xsrf': 'x-pack/ftr/services/security/role_mappings' },
|
||||
baseURL: url,
|
||||
maxRedirects: 0,
|
||||
validateStatus: () => true, // we do our own validation below and throw better error messages
|
||||
});
|
||||
}
|
||||
|
||||
public async create(name: string, roleMapping: Record<string, any>) {
|
||||
this.log.debug(`creating role mapping ${name}`);
|
||||
const { data, status, statusText } = await this.axios.post(
|
||||
`/internal/security/role_mapping/${name}`,
|
||||
roleMapping
|
||||
);
|
||||
if (status !== 200) {
|
||||
throw new Error(
|
||||
`Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}`
|
||||
);
|
||||
}
|
||||
this.log.debug(`created role mapping ${name}`);
|
||||
}
|
||||
|
||||
public async delete(name: string) {
|
||||
this.log.debug(`deleting role mapping ${name}`);
|
||||
const { data, status, statusText } = await this.axios.delete(
|
||||
`/internal/security/role_mapping/${name}`
|
||||
);
|
||||
if (status !== 200 && status !== 404) {
|
||||
throw new Error(
|
||||
`Expected status code of 200 or 404, received ${status} ${statusText}: ${util.inspect(
|
||||
data
|
||||
)}`
|
||||
);
|
||||
}
|
||||
this.log.debug(`deleted role mapping ${name}`);
|
||||
}
|
||||
}
|
|
@ -21,6 +21,7 @@ import { format as formatUrl } from 'url';
|
|||
|
||||
import { Role } from './role';
|
||||
import { User } from './user';
|
||||
import { RoleMappings } from './role_mappings';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export function SecurityServiceProvider({ getService }: FtrProviderContext) {
|
||||
|
@ -30,6 +31,7 @@ export function SecurityServiceProvider({ getService }: FtrProviderContext) {
|
|||
|
||||
return new (class SecurityService {
|
||||
role = new Role(url, log);
|
||||
roleMappings = new RoleMappings(url, log);
|
||||
user = new User(url, log);
|
||||
})();
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) {
|
|||
'\\.(css|less|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/style_mock.js`,
|
||||
'^test_utils/enzyme_helpers': `${xPackKibanaDirectory}/test_utils/enzyme_helpers.tsx`,
|
||||
'^test_utils/find_test_subject': `${xPackKibanaDirectory}/test_utils/find_test_subject.ts`,
|
||||
'^test_utils/stub_web_worker': `${xPackKibanaDirectory}/test_utils/stub_web_worker.ts`,
|
||||
},
|
||||
coverageDirectory: '<rootDir>/../target/kibana-coverage/jest',
|
||||
coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html'],
|
||||
|
|
|
@ -11,12 +11,17 @@ export {
|
|||
BuiltinESPrivileges,
|
||||
EditUser,
|
||||
FeaturesPrivileges,
|
||||
InlineRoleTemplate,
|
||||
InvalidRoleTemplate,
|
||||
KibanaPrivileges,
|
||||
RawKibanaFeaturePrivileges,
|
||||
RawKibanaPrivileges,
|
||||
Role,
|
||||
RoleIndexPrivilege,
|
||||
RoleKibanaPrivilege,
|
||||
RoleMapping,
|
||||
RoleTemplate,
|
||||
StoredRoleTemplate,
|
||||
User,
|
||||
canUserChangePassword,
|
||||
getUserDisplayName,
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 { CoreSetup } from 'src/core/public';
|
||||
import { RoleMapping } from '../../common/model';
|
||||
|
||||
interface CheckRoleMappingFeaturesResponse {
|
||||
canManageRoleMappings: boolean;
|
||||
canUseInlineScripts: boolean;
|
||||
canUseStoredScripts: boolean;
|
||||
hasCompatibleRealms: boolean;
|
||||
}
|
||||
|
||||
type DeleteRoleMappingsResponse = Array<{
|
||||
name: string;
|
||||
success: boolean;
|
||||
error?: Error;
|
||||
}>;
|
||||
|
||||
export class RoleMappingsAPI {
|
||||
constructor(private readonly http: CoreSetup['http']) {}
|
||||
|
||||
public async checkRoleMappingFeatures(): Promise<CheckRoleMappingFeaturesResponse> {
|
||||
return this.http.get(`/internal/security/_check_role_mapping_features`);
|
||||
}
|
||||
|
||||
public async getRoleMappings(): Promise<RoleMapping[]> {
|
||||
return this.http.get(`/internal/security/role_mapping`);
|
||||
}
|
||||
|
||||
public async getRoleMapping(name: string): Promise<RoleMapping> {
|
||||
return this.http.get(`/internal/security/role_mapping/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
public async saveRoleMapping(roleMapping: RoleMapping) {
|
||||
const payload = { ...roleMapping };
|
||||
delete payload.name;
|
||||
|
||||
return this.http.post(
|
||||
`/internal/security/role_mapping/${encodeURIComponent(roleMapping.name)}`,
|
||||
{ body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
public async deleteRoleMappings(names: string[]): Promise<DeleteRoleMappingsResponse> {
|
||||
return Promise.all(
|
||||
names.map(name =>
|
||||
this.http
|
||||
.delete(`/internal/security/role_mapping/${encodeURIComponent(name)}`)
|
||||
.then(() => ({ success: true, name }))
|
||||
.catch(error => ({ success: false, name, error }))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
@import './change_password_form/index';
|
||||
@import './edit_role/index';
|
||||
@import './edit_user/index';
|
||||
@import './edit_user/index';
|
||||
@import './role_mappings/edit_role_mapping/index';
|
|
@ -86,3 +86,30 @@ export function getApiKeysBreadcrumbs() {
|
|||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getRoleMappingBreadcrumbs() {
|
||||
return [
|
||||
MANAGEMENT_BREADCRUMB,
|
||||
{
|
||||
text: i18n.translate('xpack.security.roleMapping.breadcrumb', {
|
||||
defaultMessage: 'Role Mappings',
|
||||
}),
|
||||
href: '#/management/security/role_mappings',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getEditRoleMappingBreadcrumbs($route: Record<string, any>) {
|
||||
const { name } = $route.current.params;
|
||||
return [
|
||||
...getRoleMappingBreadcrumbs(),
|
||||
{
|
||||
text:
|
||||
name ||
|
||||
i18n.translate('xpack.security.roleMappings.createBreadcrumb', {
|
||||
defaultMessage: 'Create',
|
||||
}),
|
||||
href: `#/management/security/role_mappings/edit/${name}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
@ -11,9 +11,11 @@ import 'plugins/security/views/management/roles_grid/roles';
|
|||
import 'plugins/security/views/management/api_keys_grid/api_keys';
|
||||
import 'plugins/security/views/management/edit_user/edit_user';
|
||||
import 'plugins/security/views/management/edit_role/index';
|
||||
import 'plugins/security/views/management/role_mappings/role_mappings_grid';
|
||||
import 'plugins/security/views/management/role_mappings/edit_role_mapping';
|
||||
import routes from 'ui/routes';
|
||||
import { xpackInfo } from 'plugins/xpack_main/services/xpack_info';
|
||||
import { ROLES_PATH, USERS_PATH, API_KEYS_PATH } from './management_urls';
|
||||
import { ROLES_PATH, USERS_PATH, API_KEYS_PATH, ROLE_MAPPINGS_PATH } from './management_urls';
|
||||
|
||||
import { management } from 'ui/management';
|
||||
import { npSetup } from 'ui/new_platform';
|
||||
|
@ -38,11 +40,23 @@ routes
|
|||
resolve: {
|
||||
securityManagementSection: function() {
|
||||
const showSecurityLinks = xpackInfo.get('features.security.showLinks');
|
||||
const showRoleMappingsManagementLink = xpackInfo.get(
|
||||
'features.security.showRoleMappingsManagement'
|
||||
);
|
||||
|
||||
function deregisterSecurity() {
|
||||
management.deregister('security');
|
||||
}
|
||||
|
||||
function deregisterRoleMappingsManagement() {
|
||||
if (management.hasItem('security')) {
|
||||
const security = management.getSection('security');
|
||||
if (security.hasItem('roleMappings')) {
|
||||
security.deregister('roleMappings');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSecurityRegistered() {
|
||||
const registerSecurity = () =>
|
||||
management.register('security', {
|
||||
|
@ -88,11 +102,26 @@ routes
|
|||
url: `#${API_KEYS_PATH}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (showRoleMappingsManagementLink && !security.hasItem('roleMappings')) {
|
||||
security.register('roleMappings', {
|
||||
name: 'securityRoleMappingLink',
|
||||
order: 30,
|
||||
display: i18n.translate('xpack.security.management.roleMappingsTitle', {
|
||||
defaultMessage: 'Role Mappings',
|
||||
}),
|
||||
url: `#${ROLE_MAPPINGS_PATH}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!showSecurityLinks) {
|
||||
deregisterSecurity();
|
||||
} else {
|
||||
if (!showRoleMappingsManagementLink) {
|
||||
deregisterRoleMappingsManagement();
|
||||
}
|
||||
|
||||
// getCurrentUser will reject if there is no authenticated user, so we prevent them from
|
||||
// seeing the security management screens.
|
||||
return npSetup.plugins.security.authc
|
||||
|
|
|
@ -12,3 +12,13 @@ export const CLONE_ROLES_PATH = `${ROLES_PATH}/clone`;
|
|||
export const USERS_PATH = `${SECURITY_PATH}/users`;
|
||||
export const EDIT_USERS_PATH = `${USERS_PATH}/edit`;
|
||||
export const API_KEYS_PATH = `${SECURITY_PATH}/api_keys`;
|
||||
export const ROLE_MAPPINGS_PATH = `${SECURITY_PATH}/role_mappings`;
|
||||
export const CREATE_ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/edit`;
|
||||
|
||||
export const getEditRoleHref = (roleName: string) =>
|
||||
`#${EDIT_ROLES_PATH}/${encodeURIComponent(roleName)}`;
|
||||
|
||||
export const getCreateRoleMappingHref = () => `#${CREATE_ROLE_MAPPING_PATH}`;
|
||||
|
||||
export const getEditRoleMappingHref = (roleMappingName: string) =>
|
||||
`#${CREATE_ROLE_MAPPING_PATH}/${encodeURIComponent(roleMappingName)}`;
|
||||
|
|
|
@ -0,0 +1,301 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
|
||||
import { DeleteProvider } from '.';
|
||||
import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
|
||||
import { RoleMapping } from '../../../../../../common/model';
|
||||
import { EuiConfirmModal } from '@elastic/eui';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
import { act } from '@testing-library/react';
|
||||
import { toastNotifications } from 'ui/notify';
|
||||
|
||||
jest.mock('ui/notify', () => {
|
||||
return {
|
||||
toastNotifications: {
|
||||
addError: jest.fn(),
|
||||
addSuccess: jest.fn(),
|
||||
addDanger: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('DeleteProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('allows a single role mapping to be deleted', async () => {
|
||||
const props = {
|
||||
roleMappingsAPI: ({
|
||||
deleteRoleMappings: jest.fn().mockReturnValue(
|
||||
Promise.resolve([
|
||||
{
|
||||
name: 'delete-me',
|
||||
success: true,
|
||||
},
|
||||
])
|
||||
),
|
||||
} as unknown) as RoleMappingsAPI,
|
||||
};
|
||||
|
||||
const roleMappingsToDelete = [
|
||||
{
|
||||
name: 'delete-me',
|
||||
},
|
||||
] as RoleMapping[];
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<DeleteProvider {...props}>
|
||||
{onDelete => (
|
||||
<button id="invoker" onClick={() => act(() => onDelete(roleMappingsToDelete, onSuccess))}>
|
||||
initiate delete
|
||||
</button>
|
||||
)}
|
||||
</DeleteProvider>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('#invoker').simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
const { title, confirmButtonText } = wrapper.find(EuiConfirmModal).props();
|
||||
expect(title).toMatchInlineSnapshot(`"Delete role mapping 'delete-me'?"`);
|
||||
expect(confirmButtonText).toMatchInlineSnapshot(`"Delete role mapping"`);
|
||||
|
||||
await act(async () => {
|
||||
findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['delete-me']);
|
||||
|
||||
const notifications = toastNotifications as jest.Mocked<typeof toastNotifications>;
|
||||
expect(notifications.addError).toHaveBeenCalledTimes(0);
|
||||
expect(notifications.addDanger).toHaveBeenCalledTimes(0);
|
||||
expect(notifications.addSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"data-test-subj": "deletedRoleMappingSuccessToast",
|
||||
"title": "Deleted role mapping 'delete-me'",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('allows multiple role mappings to be deleted', async () => {
|
||||
const props = {
|
||||
roleMappingsAPI: ({
|
||||
deleteRoleMappings: jest.fn().mockReturnValue(
|
||||
Promise.resolve([
|
||||
{
|
||||
name: 'delete-me',
|
||||
success: true,
|
||||
},
|
||||
{
|
||||
name: 'delete-me-too',
|
||||
success: true,
|
||||
},
|
||||
])
|
||||
),
|
||||
} as unknown) as RoleMappingsAPI,
|
||||
};
|
||||
|
||||
const roleMappingsToDelete = [
|
||||
{
|
||||
name: 'delete-me',
|
||||
},
|
||||
{
|
||||
name: 'delete-me-too',
|
||||
},
|
||||
] as RoleMapping[];
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<DeleteProvider {...props}>
|
||||
{onDelete => (
|
||||
<button id="invoker" onClick={() => act(() => onDelete(roleMappingsToDelete, onSuccess))}>
|
||||
initiate delete
|
||||
</button>
|
||||
)}
|
||||
</DeleteProvider>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('#invoker').simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
const { title, confirmButtonText } = wrapper.find(EuiConfirmModal).props();
|
||||
expect(title).toMatchInlineSnapshot(`"Delete 2 role mappings?"`);
|
||||
expect(confirmButtonText).toMatchInlineSnapshot(`"Delete role mappings"`);
|
||||
|
||||
await act(async () => {
|
||||
findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith([
|
||||
'delete-me',
|
||||
'delete-me-too',
|
||||
]);
|
||||
const notifications = toastNotifications as jest.Mocked<typeof toastNotifications>;
|
||||
expect(notifications.addError).toHaveBeenCalledTimes(0);
|
||||
expect(notifications.addDanger).toHaveBeenCalledTimes(0);
|
||||
expect(notifications.addSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"data-test-subj": "deletedRoleMappingSuccessToast",
|
||||
"title": "Deleted 2 role mappings",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('handles mixed success/failure conditions', async () => {
|
||||
const props = {
|
||||
roleMappingsAPI: ({
|
||||
deleteRoleMappings: jest.fn().mockReturnValue(
|
||||
Promise.resolve([
|
||||
{
|
||||
name: 'delete-me',
|
||||
success: true,
|
||||
},
|
||||
{
|
||||
name: 'i-wont-work',
|
||||
success: false,
|
||||
error: new Error('something went wrong. sad.'),
|
||||
},
|
||||
])
|
||||
),
|
||||
} as unknown) as RoleMappingsAPI,
|
||||
};
|
||||
|
||||
const roleMappingsToDelete = [
|
||||
{
|
||||
name: 'delete-me',
|
||||
},
|
||||
{
|
||||
name: 'i-wont-work',
|
||||
},
|
||||
] as RoleMapping[];
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<DeleteProvider {...props}>
|
||||
{onDelete => (
|
||||
<button id="invoker" onClick={() => act(() => onDelete(roleMappingsToDelete, onSuccess))}>
|
||||
initiate delete
|
||||
</button>
|
||||
)}
|
||||
</DeleteProvider>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('#invoker').simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith([
|
||||
'delete-me',
|
||||
'i-wont-work',
|
||||
]);
|
||||
|
||||
const notifications = toastNotifications as jest.Mocked<typeof toastNotifications>;
|
||||
expect(notifications.addError).toHaveBeenCalledTimes(0);
|
||||
expect(notifications.addSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"data-test-subj": "deletedRoleMappingSuccessToast",
|
||||
"title": "Deleted role mapping 'delete-me'",
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
expect(notifications.addDanger).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.addDanger.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"Error deleting role mapping 'i-wont-work'",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('handles errors calling the API', async () => {
|
||||
const props = {
|
||||
roleMappingsAPI: ({
|
||||
deleteRoleMappings: jest.fn().mockImplementation(() => {
|
||||
throw new Error('AHHHHH');
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI,
|
||||
};
|
||||
|
||||
const roleMappingsToDelete = [
|
||||
{
|
||||
name: 'delete-me',
|
||||
},
|
||||
] as RoleMapping[];
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<DeleteProvider {...props}>
|
||||
{onDelete => (
|
||||
<button id="invoker" onClick={() => act(() => onDelete(roleMappingsToDelete, onSuccess))}>
|
||||
initiate delete
|
||||
</button>
|
||||
)}
|
||||
</DeleteProvider>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('#invoker').simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['delete-me']);
|
||||
|
||||
const notifications = toastNotifications as jest.Mocked<typeof toastNotifications>;
|
||||
expect(notifications.addDanger).toHaveBeenCalledTimes(0);
|
||||
expect(notifications.addSuccess).toHaveBeenCalledTimes(0);
|
||||
|
||||
expect(notifications.addError).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.addError.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[Error: AHHHHH],
|
||||
Object {
|
||||
"title": "Error deleting role mappings",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,198 @@
|
|||
/*
|
||||
* 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 React, { Fragment, useRef, useState, ReactElement } from 'react';
|
||||
import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
|
||||
import { toastNotifications } from 'ui/notify';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { RoleMapping } from '../../../../../../common/model';
|
||||
import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
|
||||
|
||||
interface Props {
|
||||
roleMappingsAPI: RoleMappingsAPI;
|
||||
children: (deleteMappings: DeleteRoleMappings) => ReactElement;
|
||||
}
|
||||
|
||||
export type DeleteRoleMappings = (
|
||||
roleMappings: RoleMapping[],
|
||||
onSuccess?: OnSuccessCallback
|
||||
) => void;
|
||||
|
||||
type OnSuccessCallback = (deletedRoleMappings: string[]) => void;
|
||||
|
||||
export const DeleteProvider: React.FunctionComponent<Props> = ({ roleMappingsAPI, children }) => {
|
||||
const [roleMappings, setRoleMappings] = useState<RoleMapping[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteInProgress, setIsDeleteInProgress] = useState(false);
|
||||
|
||||
const onSuccessCallback = useRef<OnSuccessCallback | null>(null);
|
||||
|
||||
const deleteRoleMappingsPrompt: DeleteRoleMappings = (
|
||||
roleMappingsToDelete,
|
||||
onSuccess = () => undefined
|
||||
) => {
|
||||
if (!roleMappingsToDelete || !roleMappingsToDelete.length) {
|
||||
throw new Error('No Role Mappings specified for delete');
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
setRoleMappings(roleMappingsToDelete);
|
||||
onSuccessCallback.current = onSuccess;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setRoleMappings([]);
|
||||
};
|
||||
|
||||
const deleteRoleMappings = async () => {
|
||||
let result;
|
||||
|
||||
setIsDeleteInProgress(true);
|
||||
|
||||
try {
|
||||
result = await roleMappingsAPI.deleteRoleMappings(roleMappings.map(rm => rm.name));
|
||||
} catch (e) {
|
||||
toastNotifications.addError(e, {
|
||||
title: i18n.translate(
|
||||
'xpack.security.management.roleMappings.deleteRoleMapping.unknownError',
|
||||
{
|
||||
defaultMessage: 'Error deleting role mappings',
|
||||
}
|
||||
),
|
||||
});
|
||||
setIsDeleteInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleteInProgress(false);
|
||||
|
||||
closeModal();
|
||||
|
||||
const successfulDeletes = result.filter(res => res.success);
|
||||
const erroredDeletes = result.filter(res => !res.success);
|
||||
|
||||
// Surface success notifications
|
||||
if (successfulDeletes.length > 0) {
|
||||
const hasMultipleSuccesses = successfulDeletes.length > 1;
|
||||
const successMessage = hasMultipleSuccesses
|
||||
? i18n.translate(
|
||||
'xpack.security.management.roleMappings.deleteRoleMapping.successMultipleNotificationTitle',
|
||||
{
|
||||
defaultMessage: 'Deleted {count} role mappings',
|
||||
values: { count: successfulDeletes.length },
|
||||
}
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.security.management.roleMappings.deleteRoleMapping.successSingleNotificationTitle',
|
||||
{
|
||||
defaultMessage: "Deleted role mapping '{name}'",
|
||||
values: { name: successfulDeletes[0].name },
|
||||
}
|
||||
);
|
||||
toastNotifications.addSuccess({
|
||||
title: successMessage,
|
||||
'data-test-subj': 'deletedRoleMappingSuccessToast',
|
||||
});
|
||||
if (onSuccessCallback.current) {
|
||||
onSuccessCallback.current(successfulDeletes.map(({ name }) => name));
|
||||
}
|
||||
}
|
||||
|
||||
// Surface error notifications
|
||||
if (erroredDeletes.length > 0) {
|
||||
const hasMultipleErrors = erroredDeletes.length > 1;
|
||||
const errorMessage = hasMultipleErrors
|
||||
? i18n.translate(
|
||||
'xpack.security.management.roleMappings.deleteRoleMapping.errorMultipleNotificationTitle',
|
||||
{
|
||||
defaultMessage: 'Error deleting {count} role mappings',
|
||||
values: {
|
||||
count: erroredDeletes.length,
|
||||
},
|
||||
}
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.security.management.roleMappings.deleteRoleMapping.errorSingleNotificationTitle',
|
||||
{
|
||||
defaultMessage: "Error deleting role mapping '{name}'",
|
||||
values: { name: erroredDeletes[0].name },
|
||||
}
|
||||
);
|
||||
toastNotifications.addDanger(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const renderModal = () => {
|
||||
if (!isModalOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSingle = roleMappings.length === 1;
|
||||
|
||||
return (
|
||||
<EuiOverlayMask>
|
||||
<EuiConfirmModal
|
||||
title={
|
||||
isSingle
|
||||
? i18n.translate(
|
||||
'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.deleteSingleTitle',
|
||||
{
|
||||
defaultMessage: "Delete role mapping '{name}'?",
|
||||
values: { name: roleMappings[0].name },
|
||||
}
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.deleteMultipleTitle',
|
||||
{
|
||||
defaultMessage: 'Delete {count} role mappings?',
|
||||
values: { count: roleMappings.length },
|
||||
}
|
||||
)
|
||||
}
|
||||
onCancel={closeModal}
|
||||
onConfirm={deleteRoleMappings}
|
||||
cancelButtonText={i18n.translate(
|
||||
'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.cancelButtonLabel',
|
||||
{ defaultMessage: 'Cancel' }
|
||||
)}
|
||||
confirmButtonText={i18n.translate(
|
||||
'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.confirmButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Delete {count, plural, one {role mapping} other {role mappings}}',
|
||||
values: { count: roleMappings.length },
|
||||
}
|
||||
)}
|
||||
confirmButtonDisabled={isDeleteInProgress}
|
||||
buttonColor="danger"
|
||||
data-test-subj="deleteRoleMappingConfirmationModal"
|
||||
>
|
||||
{!isSingle ? (
|
||||
<Fragment>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.deleteMultipleListDescription',
|
||||
{ defaultMessage: 'You are about to delete these role mappings:' }
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
{roleMappings.map(({ name }) => (
|
||||
<li key={name}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Fragment>
|
||||
) : null}
|
||||
</EuiConfirmModal>
|
||||
</EuiOverlayMask>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{children(deleteRoleMappingsPrompt)}
|
||||
{renderModal()}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
|
@ -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 { DeleteProvider } from './delete_provider';
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 * from './delete_provider';
|
||||
export * from './no_compatible_realms';
|
||||
export * from './permission_denied';
|
||||
export * from './section_loading';
|
|
@ -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 { NoCompatibleRealms } from './no_compatible_realms';
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { EuiCallOut, EuiLink } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { documentationLinks } from '../../services/documentation_links';
|
||||
|
||||
export const NoCompatibleRealms: React.FunctionComponent = () => (
|
||||
<EuiCallOut
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.noCompatibleRealmsErrorTitle"
|
||||
defaultMessage="No compatible realms are enabled in Elasticsearch"
|
||||
/>
|
||||
}
|
||||
color="warning"
|
||||
iconType="alert"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.noCompatibleRealmsErrorDescription"
|
||||
defaultMessage="Role mappings will not be applied to any users. Contact your system administrator and refer to the {link} for more information."
|
||||
values={{
|
||||
link: (
|
||||
<EuiLink href={documentationLinks.getRoleMappingDocUrl()} external={true} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.noCompatibleRealmsErrorLinkText"
|
||||
defaultMessage="docs"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
);
|
|
@ -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 { PermissionDenied } from './permission_denied';
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 { EuiEmptyPrompt, EuiFlexGroup, EuiPageContent } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import React from 'react';
|
||||
|
||||
export const PermissionDenied = () => (
|
||||
<EuiFlexGroup gutterSize="none">
|
||||
<EuiPageContent horizontalPosition="center">
|
||||
<EuiEmptyPrompt
|
||||
iconType="securityApp"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.deniedPermissionTitle"
|
||||
defaultMessage="You need permission to manage role mappings"
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
body={
|
||||
<p data-test-subj="permissionDeniedMessage">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.deniedPermissionDescription"
|
||||
defaultMessage="Contact your system administrator."
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</EuiPageContent>
|
||||
</EuiFlexGroup>
|
||||
);
|
|
@ -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 { SectionLoading } from './section_loading';
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { SectionLoading } from '.';
|
||||
|
||||
describe('SectionLoading', () => {
|
||||
it('renders the default loading message', () => {
|
||||
const wrapper = shallowWithIntl(<SectionLoading />);
|
||||
expect(wrapper.props().body).toMatchInlineSnapshot(`
|
||||
<EuiText
|
||||
color="subdued"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Loading…"
|
||||
id="xpack.security.management.editRoleMapping.loadingRoleMappingDescription"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiText>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders the custom message when provided', () => {
|
||||
const custom = <div>hold your horses</div>;
|
||||
const wrapper = shallowWithIntl(<SectionLoading>{custom}</SectionLoading>);
|
||||
expect(wrapper.props().body).toMatchInlineSnapshot(`
|
||||
<EuiText
|
||||
color="subdued"
|
||||
>
|
||||
<div>
|
||||
hold your horses
|
||||
</div>
|
||||
</EuiText>
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactChild;
|
||||
}
|
||||
export const SectionLoading = (props: Props) => {
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
title={<EuiLoadingSpinner size="xl" />}
|
||||
body={
|
||||
<EuiText color="subdued">
|
||||
{props.children || (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.loadingRoleMappingDescription"
|
||||
defaultMessage="Loading…"
|
||||
/>
|
||||
)}
|
||||
</EuiText>
|
||||
}
|
||||
data-test-subj="sectionLoading"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
@import './components/rule_editor_panel/index';
|
|
@ -0,0 +1,341 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
|
||||
// brace/ace uses the Worker class, which is not currently provided by JSDOM.
|
||||
// This is not required for the tests to pass, but it rather suppresses lengthy
|
||||
// warnings in the console which adds unnecessary noise to the test output.
|
||||
import 'test_utils/stub_web_worker';
|
||||
|
||||
import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
|
||||
import { EditRoleMappingPage } from '.';
|
||||
import { NoCompatibleRealms, SectionLoading, PermissionDenied } from '../../components';
|
||||
import { VisualRuleEditor } from './rule_editor_panel/visual_rule_editor';
|
||||
import { JSONRuleEditor } from './rule_editor_panel/json_rule_editor';
|
||||
import { EuiComboBox } from '@elastic/eui';
|
||||
|
||||
jest.mock('../../../../../lib/roles_api', () => {
|
||||
return {
|
||||
RolesApi: {
|
||||
getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('EditRoleMappingPage', () => {
|
||||
it('allows a role mapping to be created', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
saveRoleMapping: jest.fn().mockResolvedValue(null),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: true,
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: true,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(<EditRoleMappingPage roleMappingsAPI={roleMappingsAPI} />);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
findTestSubject(wrapper, 'roleMappingFormNameInput').simulate('change', {
|
||||
target: { value: 'my-role-mapping' },
|
||||
});
|
||||
|
||||
(wrapper
|
||||
.find(EuiComboBox)
|
||||
.filter('[data-test-subj="roleMappingFormRoleComboBox"]')
|
||||
.props() as any).onChange([{ label: 'foo_role' }]);
|
||||
|
||||
findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
|
||||
|
||||
findTestSubject(wrapper, 'saveRoleMappingButton').simulate('click');
|
||||
|
||||
expect(roleMappingsAPI.saveRoleMapping).toHaveBeenCalledWith({
|
||||
name: 'my-role-mapping',
|
||||
enabled: true,
|
||||
roles: ['foo_role'],
|
||||
role_templates: [],
|
||||
rules: {
|
||||
all: [{ field: { username: '*' } }],
|
||||
},
|
||||
metadata: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a role mapping to be updated', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
saveRoleMapping: jest.fn().mockResolvedValue(null),
|
||||
getRoleMapping: jest.fn().mockResolvedValue({
|
||||
name: 'foo',
|
||||
role_templates: [
|
||||
{
|
||||
template: { id: 'foo' },
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
rules: {
|
||||
any: [{ field: { 'metadata.someCustomOption': [false, true, 'asdf'] } }],
|
||||
},
|
||||
metadata: {
|
||||
foo: 'bar',
|
||||
bar: 'baz',
|
||||
},
|
||||
}),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: true,
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: true,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<EditRoleMappingPage name="foo" roleMappingsAPI={roleMappingsAPI} />
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
findTestSubject(wrapper, 'switchToRolesButton').simulate('click');
|
||||
|
||||
(wrapper
|
||||
.find(EuiComboBox)
|
||||
.filter('[data-test-subj="roleMappingFormRoleComboBox"]')
|
||||
.props() as any).onChange([{ label: 'foo_role' }]);
|
||||
|
||||
findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
|
||||
wrapper.find('button[id="addRuleOption"]').simulate('click');
|
||||
|
||||
findTestSubject(wrapper, 'saveRoleMappingButton').simulate('click');
|
||||
|
||||
expect(roleMappingsAPI.saveRoleMapping).toHaveBeenCalledWith({
|
||||
name: 'foo',
|
||||
enabled: true,
|
||||
roles: ['foo_role'],
|
||||
role_templates: [],
|
||||
rules: {
|
||||
any: [
|
||||
{ field: { 'metadata.someCustomOption': [false, true, 'asdf'] } },
|
||||
{ field: { username: '*' } },
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
foo: 'bar',
|
||||
bar: 'baz',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a permission denied message when unauthorized to manage role mappings', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: false,
|
||||
hasCompatibleRealms: true,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(<EditRoleMappingPage roleMappingsAPI={roleMappingsAPI} />);
|
||||
expect(wrapper.find(SectionLoading)).toHaveLength(1);
|
||||
expect(wrapper.find(PermissionDenied)).toHaveLength(0);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(SectionLoading)).toHaveLength(0);
|
||||
expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0);
|
||||
expect(wrapper.find(PermissionDenied)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a warning when there are no compatible realms enabled', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: false,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(<EditRoleMappingPage roleMappingsAPI={roleMappingsAPI} />);
|
||||
expect(wrapper.find(SectionLoading)).toHaveLength(1);
|
||||
expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(SectionLoading)).toHaveLength(0);
|
||||
expect(wrapper.find(NoCompatibleRealms)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a warning when editing a mapping with a stored role template, when stored scripts are disabled', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
getRoleMapping: jest.fn().mockResolvedValue({
|
||||
name: 'foo',
|
||||
role_templates: [
|
||||
{
|
||||
template: { id: 'foo' },
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
rules: {
|
||||
field: { username: '*' },
|
||||
},
|
||||
}),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: true,
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: false,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<EditRoleMappingPage name={'foo'} roleMappingsAPI={roleMappingsAPI} />
|
||||
);
|
||||
|
||||
expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0);
|
||||
expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0);
|
||||
expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a warning when editing a mapping with an inline role template, when inline scripts are disabled', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
getRoleMapping: jest.fn().mockResolvedValue({
|
||||
name: 'foo',
|
||||
role_templates: [
|
||||
{
|
||||
template: { source: 'foo' },
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
rules: {
|
||||
field: { username: '*' },
|
||||
},
|
||||
}),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: true,
|
||||
canUseInlineScripts: false,
|
||||
canUseStoredScripts: true,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<EditRoleMappingPage name={'foo'} roleMappingsAPI={roleMappingsAPI} />
|
||||
);
|
||||
|
||||
expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0);
|
||||
expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(1);
|
||||
expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the visual editor by default for simple rule sets', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
getRoleMapping: jest.fn().mockResolvedValue({
|
||||
name: 'foo',
|
||||
roles: ['superuser'],
|
||||
enabled: true,
|
||||
rules: {
|
||||
all: [
|
||||
{
|
||||
field: {
|
||||
username: '*',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
dn: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
realm: ['ldap', 'pki', null, 12],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: true,
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: true,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<EditRoleMappingPage name={'foo'} roleMappingsAPI={roleMappingsAPI} />
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(VisualRuleEditor)).toHaveLength(1);
|
||||
expect(wrapper.find(JSONRuleEditor)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the JSON editor by default for complex rule sets', async () => {
|
||||
const createRule = (depth: number): Record<string, any> => {
|
||||
if (depth > 0) {
|
||||
const rule = {
|
||||
all: [
|
||||
{
|
||||
field: {
|
||||
username: '*',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as Record<string, any>;
|
||||
|
||||
const subRule = createRule(depth - 1);
|
||||
if (subRule) {
|
||||
rule.all.push(subRule);
|
||||
}
|
||||
|
||||
return rule;
|
||||
}
|
||||
return null as any;
|
||||
};
|
||||
|
||||
const roleMappingsAPI = ({
|
||||
getRoleMapping: jest.fn().mockResolvedValue({
|
||||
name: 'foo',
|
||||
roles: ['superuser'],
|
||||
enabled: true,
|
||||
rules: createRule(10),
|
||||
}),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: true,
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: true,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<EditRoleMappingPage name={'foo'} roleMappingsAPI={roleMappingsAPI} />
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(VisualRuleEditor)).toHaveLength(0);
|
||||
expect(wrapper.find(JSONRuleEditor)).toHaveLength(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,332 @@
|
|||
/*
|
||||
* 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 React, { Component, Fragment } from 'react';
|
||||
import {
|
||||
EuiForm,
|
||||
EuiPageContent,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiButtonEmpty,
|
||||
EuiButton,
|
||||
EuiLink,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { toastNotifications } from 'ui/notify';
|
||||
import { RoleMapping } from '../../../../../../common/model';
|
||||
import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
|
||||
import { RuleEditorPanel } from './rule_editor_panel';
|
||||
import {
|
||||
NoCompatibleRealms,
|
||||
PermissionDenied,
|
||||
DeleteProvider,
|
||||
SectionLoading,
|
||||
} from '../../components';
|
||||
import { ROLE_MAPPINGS_PATH } from '../../../management_urls';
|
||||
import { validateRoleMappingForSave } from '../services/role_mapping_validation';
|
||||
import { MappingInfoPanel } from './mapping_info_panel';
|
||||
import { documentationLinks } from '../../services/documentation_links';
|
||||
|
||||
interface State {
|
||||
loadState: 'loading' | 'permissionDenied' | 'ready' | 'saveInProgress';
|
||||
roleMapping: RoleMapping | null;
|
||||
hasCompatibleRealms: boolean;
|
||||
canUseStoredScripts: boolean;
|
||||
canUseInlineScripts: boolean;
|
||||
formError: {
|
||||
isInvalid: boolean;
|
||||
error?: string;
|
||||
};
|
||||
validateForm: boolean;
|
||||
rulesValid: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
roleMappingsAPI: RoleMappingsAPI;
|
||||
}
|
||||
|
||||
export class EditRoleMappingPage extends Component<Props, State> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loadState: 'loading',
|
||||
roleMapping: null,
|
||||
hasCompatibleRealms: true,
|
||||
canUseStoredScripts: true,
|
||||
canUseInlineScripts: true,
|
||||
rulesValid: true,
|
||||
validateForm: false,
|
||||
formError: {
|
||||
isInvalid: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.loadAppData();
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { loadState } = this.state;
|
||||
|
||||
if (loadState === 'permissionDenied') {
|
||||
return <PermissionDenied />;
|
||||
}
|
||||
|
||||
if (loadState === 'loading') {
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<SectionLoading />
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EuiForm isInvalid={this.state.formError.isInvalid} error={this.state.formError.error}>
|
||||
{this.getFormTitle()}
|
||||
<EuiSpacer />
|
||||
<MappingInfoPanel
|
||||
roleMapping={this.state.roleMapping!}
|
||||
onChange={roleMapping => this.setState({ roleMapping })}
|
||||
mode={this.editingExistingRoleMapping() ? 'edit' : 'create'}
|
||||
validateForm={this.state.validateForm}
|
||||
canUseInlineScripts={this.state.canUseInlineScripts}
|
||||
canUseStoredScripts={this.state.canUseStoredScripts}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
<RuleEditorPanel
|
||||
rawRules={this.state.roleMapping!.rules}
|
||||
validateForm={this.state.validateForm}
|
||||
onValidityChange={this.onRuleValidityChange}
|
||||
onChange={rules =>
|
||||
this.setState({
|
||||
roleMapping: {
|
||||
...this.state.roleMapping!,
|
||||
rules,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
{this.getFormButtons()}
|
||||
</EuiForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private getFormTitle = () => {
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiTitle size="l">
|
||||
<h1>
|
||||
{this.editingExistingRoleMapping() ? (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.editRoleMappingTitle"
|
||||
defaultMessage="Edit role mapping"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.createRoleMappingTitle"
|
||||
defaultMessage="Create role mapping"
|
||||
/>
|
||||
)}
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
<EuiText color="subdued" size="s">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingDescription"
|
||||
defaultMessage="Use role mappings to control which roles are assigned to your users. {learnMoreLink}"
|
||||
values={{
|
||||
learnMoreLink: (
|
||||
<EuiLink
|
||||
href={documentationLinks.getRoleMappingDocUrl()}
|
||||
external={true}
|
||||
target="_blank"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.learnMoreLinkText"
|
||||
defaultMessage="Learn more."
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
{!this.state.hasCompatibleRealms && (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<NoCompatibleRealms />
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
private getFormButtons = () => {
|
||||
return (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
onClick={this.saveRoleMapping}
|
||||
isLoading={this.state.loadState === 'saveInProgress'}
|
||||
disabled={!this.state.rulesValid || this.state.loadState === 'saveInProgress'}
|
||||
data-test-subj="saveRoleMappingButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.saveRoleMappingButton"
|
||||
defaultMessage="Save role mapping"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} onClick={this.backToRoleMappingsList}>
|
||||
<EuiButton>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.cancelButton"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true} />
|
||||
{this.editingExistingRoleMapping() && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<DeleteProvider roleMappingsAPI={this.props.roleMappingsAPI}>
|
||||
{deleteRoleMappingsPrompt => {
|
||||
return (
|
||||
<EuiButtonEmpty
|
||||
onClick={() =>
|
||||
deleteRoleMappingsPrompt([this.state.roleMapping!], () =>
|
||||
this.backToRoleMappingsList()
|
||||
)
|
||||
}
|
||||
color="danger"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.deleteRoleMappingButton"
|
||||
defaultMessage="Delete role mapping"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
}}
|
||||
</DeleteProvider>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
private onRuleValidityChange = (rulesValid: boolean) => {
|
||||
this.setState({
|
||||
rulesValid,
|
||||
});
|
||||
};
|
||||
|
||||
private saveRoleMapping = () => {
|
||||
if (!this.state.roleMapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isInvalid } = validateRoleMappingForSave(this.state.roleMapping);
|
||||
if (isInvalid) {
|
||||
this.setState({ validateForm: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const roleMappingName = this.state.roleMapping.name;
|
||||
|
||||
this.setState({
|
||||
loadState: 'saveInProgress',
|
||||
});
|
||||
|
||||
this.props.roleMappingsAPI
|
||||
.saveRoleMapping(this.state.roleMapping)
|
||||
.then(() => {
|
||||
toastNotifications.addSuccess({
|
||||
title: i18n.translate('xpack.security.management.editRoleMapping.saveSuccess', {
|
||||
defaultMessage: `Saved role mapping '{roleMappingName}'`,
|
||||
values: {
|
||||
roleMappingName,
|
||||
},
|
||||
}),
|
||||
'data-test-subj': 'savedRoleMappingSuccessToast',
|
||||
});
|
||||
this.backToRoleMappingsList();
|
||||
})
|
||||
.catch(e => {
|
||||
toastNotifications.addError(e, {
|
||||
title: i18n.translate('xpack.security.management.editRoleMapping.saveError', {
|
||||
defaultMessage: `Error saving role mapping`,
|
||||
}),
|
||||
toastMessage: e?.body?.message,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
loadState: 'saveInProgress',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private editingExistingRoleMapping = () => typeof this.props.name === 'string';
|
||||
|
||||
private async loadAppData() {
|
||||
try {
|
||||
const [features, roleMapping] = await Promise.all([
|
||||
this.props.roleMappingsAPI.checkRoleMappingFeatures(),
|
||||
this.editingExistingRoleMapping()
|
||||
? this.props.roleMappingsAPI.getRoleMapping(this.props.name!)
|
||||
: Promise.resolve({
|
||||
name: '',
|
||||
enabled: true,
|
||||
metadata: {},
|
||||
role_templates: [],
|
||||
roles: [],
|
||||
rules: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
const {
|
||||
canManageRoleMappings,
|
||||
canUseStoredScripts,
|
||||
canUseInlineScripts,
|
||||
hasCompatibleRealms,
|
||||
} = features;
|
||||
|
||||
const loadState: State['loadState'] = canManageRoleMappings ? 'ready' : 'permissionDenied';
|
||||
|
||||
this.setState({
|
||||
loadState,
|
||||
hasCompatibleRealms,
|
||||
canUseStoredScripts,
|
||||
canUseInlineScripts,
|
||||
roleMapping,
|
||||
});
|
||||
} catch (e) {
|
||||
toastNotifications.addDanger({
|
||||
title: i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.table.fetchingRoleMappingsErrorMessage',
|
||||
{
|
||||
defaultMessage: 'Error loading role mapping editor: {message}',
|
||||
values: { message: e?.body?.message ?? '' },
|
||||
}
|
||||
),
|
||||
'data-test-subj': 'errorLoadingRoleMappingEditorToast',
|
||||
});
|
||||
this.backToRoleMappingsList();
|
||||
}
|
||||
}
|
||||
|
||||
private backToRoleMappingsList = () => {
|
||||
window.location.hash = ROLE_MAPPINGS_PATH;
|
||||
};
|
||||
}
|
|
@ -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 { EditRoleMappingPage } from './edit_role_mapping_page';
|
|
@ -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 { MappingInfoPanel } from './mapping_info_panel';
|
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { MappingInfoPanel } from '.';
|
||||
import { RoleMapping } from '../../../../../../../common/model';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
import { RoleSelector } from '../role_selector';
|
||||
import { RoleTemplateEditor } from '../role_selector/role_template_editor';
|
||||
|
||||
jest.mock('../../../../../../lib/roles_api', () => {
|
||||
return {
|
||||
RolesApi: {
|
||||
getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('MappingInfoPanel', () => {
|
||||
it('renders when creating a role mapping, default to the "roles" view', () => {
|
||||
const props = {
|
||||
roleMapping: {
|
||||
name: 'my role mapping',
|
||||
enabled: true,
|
||||
roles: [],
|
||||
role_templates: [],
|
||||
rules: {},
|
||||
metadata: {},
|
||||
} as RoleMapping,
|
||||
mode: 'create',
|
||||
} as MappingInfoPanel['props'];
|
||||
|
||||
const wrapper = mountWithIntl(<MappingInfoPanel {...props} />);
|
||||
|
||||
// Name input validation
|
||||
const { value: nameInputValue, readOnly: nameInputReadOnly } = findTestSubject(
|
||||
wrapper,
|
||||
'roleMappingFormNameInput'
|
||||
)
|
||||
.find('input')
|
||||
.props();
|
||||
|
||||
expect(nameInputValue).toEqual(props.roleMapping.name);
|
||||
expect(nameInputReadOnly).toEqual(false);
|
||||
|
||||
// Enabled switch validation
|
||||
const { checked: enabledInputValue } = wrapper
|
||||
.find('EuiSwitch[data-test-subj="roleMappingsEnabledSwitch"]')
|
||||
.props();
|
||||
|
||||
expect(enabledInputValue).toEqual(props.roleMapping.enabled);
|
||||
|
||||
// Verify "roles" mode
|
||||
expect(wrapper.find(RoleSelector).props()).toMatchObject({
|
||||
mode: 'roles',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the role templates view if templates are provided', () => {
|
||||
const props = {
|
||||
roleMapping: {
|
||||
name: 'my role mapping',
|
||||
enabled: true,
|
||||
roles: [],
|
||||
role_templates: [
|
||||
{
|
||||
template: {
|
||||
source: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
rules: {},
|
||||
metadata: {},
|
||||
} as RoleMapping,
|
||||
mode: 'edit',
|
||||
} as MappingInfoPanel['props'];
|
||||
|
||||
const wrapper = mountWithIntl(<MappingInfoPanel {...props} />);
|
||||
|
||||
expect(wrapper.find(RoleSelector).props()).toMatchObject({
|
||||
mode: 'templates',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a blank inline template by default when switching from roles to role templates', () => {
|
||||
const props = {
|
||||
roleMapping: {
|
||||
name: 'my role mapping',
|
||||
enabled: true,
|
||||
roles: ['foo_role'],
|
||||
role_templates: [],
|
||||
rules: {},
|
||||
metadata: {},
|
||||
} as RoleMapping,
|
||||
mode: 'create' as any,
|
||||
onChange: jest.fn(),
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: false,
|
||||
validateForm: false,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<MappingInfoPanel {...props} />);
|
||||
|
||||
findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith({
|
||||
name: 'my role mapping',
|
||||
enabled: true,
|
||||
roles: [],
|
||||
role_templates: [
|
||||
{
|
||||
template: { source: '' },
|
||||
},
|
||||
],
|
||||
rules: {},
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
wrapper.setProps({ roleMapping: props.onChange.mock.calls[0][0] });
|
||||
|
||||
expect(wrapper.find(RoleTemplateEditor)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a blank stored template by default when switching from roles to role templates and inline scripts are disabled', () => {
|
||||
const props = {
|
||||
roleMapping: {
|
||||
name: 'my role mapping',
|
||||
enabled: true,
|
||||
roles: ['foo_role'],
|
||||
role_templates: [],
|
||||
rules: {},
|
||||
metadata: {},
|
||||
} as RoleMapping,
|
||||
mode: 'create' as any,
|
||||
onChange: jest.fn(),
|
||||
canUseInlineScripts: false,
|
||||
canUseStoredScripts: true,
|
||||
validateForm: false,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<MappingInfoPanel {...props} />);
|
||||
|
||||
findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith({
|
||||
name: 'my role mapping',
|
||||
enabled: true,
|
||||
roles: [],
|
||||
role_templates: [
|
||||
{
|
||||
template: { id: '' },
|
||||
},
|
||||
],
|
||||
rules: {},
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
wrapper.setProps({ roleMapping: props.onChange.mock.calls[0][0] });
|
||||
|
||||
expect(wrapper.find(RoleTemplateEditor)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not create a blank role template if no script types are enabled', () => {
|
||||
const props = {
|
||||
roleMapping: {
|
||||
name: 'my role mapping',
|
||||
enabled: true,
|
||||
roles: ['foo_role'],
|
||||
role_templates: [],
|
||||
rules: {},
|
||||
metadata: {},
|
||||
} as RoleMapping,
|
||||
mode: 'create' as any,
|
||||
onChange: jest.fn(),
|
||||
canUseInlineScripts: false,
|
||||
canUseStoredScripts: false,
|
||||
validateForm: false,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<MappingInfoPanel {...props} />);
|
||||
|
||||
findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click');
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(props.onChange).not.toHaveBeenCalled();
|
||||
expect(wrapper.find(RoleTemplateEditor)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the name input as readonly when editing an existing role mapping', () => {
|
||||
const props = {
|
||||
roleMapping: {
|
||||
name: 'my role mapping',
|
||||
enabled: true,
|
||||
roles: [],
|
||||
role_templates: [],
|
||||
rules: {},
|
||||
metadata: {},
|
||||
} as RoleMapping,
|
||||
mode: 'edit',
|
||||
} as MappingInfoPanel['props'];
|
||||
|
||||
const wrapper = mountWithIntl(<MappingInfoPanel {...props} />);
|
||||
|
||||
// Name input validation
|
||||
const { value: nameInputValue, readOnly: nameInputReadOnly } = findTestSubject(
|
||||
wrapper,
|
||||
'roleMappingFormNameInput'
|
||||
)
|
||||
.find('input')
|
||||
.props();
|
||||
|
||||
expect(nameInputValue).toEqual(props.roleMapping.name);
|
||||
expect(nameInputReadOnly).toEqual(true);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,323 @@
|
|||
/*
|
||||
* 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 React, { Component, ChangeEvent, Fragment } from 'react';
|
||||
import {
|
||||
EuiPanel,
|
||||
EuiTitle,
|
||||
EuiText,
|
||||
EuiSpacer,
|
||||
EuiDescribedFormGroup,
|
||||
EuiFormRow,
|
||||
EuiFieldText,
|
||||
EuiLink,
|
||||
EuiIcon,
|
||||
EuiSwitch,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { RoleMapping } from '../../../../../../../common/model';
|
||||
import {
|
||||
validateRoleMappingName,
|
||||
validateRoleMappingRoles,
|
||||
validateRoleMappingRoleTemplates,
|
||||
} from '../../services/role_mapping_validation';
|
||||
import { RoleSelector } from '../role_selector';
|
||||
import { documentationLinks } from '../../../services/documentation_links';
|
||||
|
||||
interface Props {
|
||||
roleMapping: RoleMapping;
|
||||
onChange: (roleMapping: RoleMapping) => void;
|
||||
mode: 'create' | 'edit';
|
||||
validateForm: boolean;
|
||||
canUseInlineScripts: boolean;
|
||||
canUseStoredScripts: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
rolesMode: 'roles' | 'templates';
|
||||
}
|
||||
|
||||
export class MappingInfoPanel extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
rolesMode:
|
||||
props.roleMapping.role_templates && props.roleMapping.role_templates.length > 0
|
||||
? 'templates'
|
||||
: 'roles',
|
||||
};
|
||||
}
|
||||
public render() {
|
||||
return (
|
||||
<EuiPanel>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingTitle"
|
||||
defaultMessage="Role mapping"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer />
|
||||
{this.getRoleMappingName()}
|
||||
{this.getEnabledSwitch()}
|
||||
{this.getRolesOrRoleTemplatesSelector()}
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
|
||||
private getRoleMappingName = () => {
|
||||
return (
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingNameFormGroupTitle"
|
||||
defaultMessage="Mapping name"
|
||||
/>
|
||||
</h3>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingNameFormGroupHelpText"
|
||||
defaultMessage="A unique name used to identify this role mapping."
|
||||
/>
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingNameFormRowTitle"
|
||||
defaultMessage="Name"
|
||||
/>
|
||||
}
|
||||
fullWidth
|
||||
{...(this.props.validateForm && validateRoleMappingName(this.props.roleMapping))}
|
||||
>
|
||||
<EuiFieldText
|
||||
name={'name'}
|
||||
value={this.props.roleMapping.name || ''}
|
||||
onChange={this.onNameChange}
|
||||
data-test-subj={'roleMappingFormNameInput'}
|
||||
readOnly={this.props.mode === 'edit'}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
);
|
||||
};
|
||||
|
||||
private getRolesOrRoleTemplatesSelector = () => {
|
||||
if (this.state.rolesMode === 'roles') {
|
||||
return this.getRolesSelector();
|
||||
}
|
||||
return this.getRoleTemplatesSelector();
|
||||
};
|
||||
|
||||
private getRolesSelector = () => {
|
||||
const validationFunction = () => {
|
||||
if (!this.props.validateForm) {
|
||||
return {};
|
||||
}
|
||||
return validateRoleMappingRoles(this.props.roleMapping);
|
||||
};
|
||||
return (
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingRolesFormRowTitle"
|
||||
defaultMessage="Roles"
|
||||
/>
|
||||
</h3>
|
||||
}
|
||||
description={
|
||||
<EuiText size="s" color="subdued">
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingRolesFormRowHelpText"
|
||||
defaultMessage="Assign roles to your users."
|
||||
/>
|
||||
</span>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiLink
|
||||
data-test-subj="switchToRoleTemplatesButton"
|
||||
onClick={() => {
|
||||
this.onRolesModeChange('templates');
|
||||
}}
|
||||
>
|
||||
<Fragment>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.switchToRoleTemplates"
|
||||
defaultMessage="Switch to role templates"
|
||||
/>{' '}
|
||||
<EuiIcon size="s" type="inputOutput" />
|
||||
</Fragment>
|
||||
</EuiLink>
|
||||
</EuiText>
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<EuiFormRow fullWidth={true} {...validationFunction()}>
|
||||
<RoleSelector
|
||||
roleMapping={this.props.roleMapping}
|
||||
mode={this.state.rolesMode}
|
||||
canUseInlineScripts={this.props.canUseInlineScripts}
|
||||
canUseStoredScripts={this.props.canUseStoredScripts}
|
||||
onChange={roleMapping => this.props.onChange(roleMapping)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
);
|
||||
};
|
||||
|
||||
private getRoleTemplatesSelector = () => {
|
||||
const validationFunction = () => {
|
||||
if (!this.props.validateForm) {
|
||||
return {};
|
||||
}
|
||||
return validateRoleMappingRoleTemplates(this.props.roleMapping);
|
||||
};
|
||||
return (
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingRoleTemplatesFormRowTitle"
|
||||
defaultMessage="Role templates"
|
||||
/>
|
||||
</h3>
|
||||
}
|
||||
description={
|
||||
<EuiText size="s" color="subdued">
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingRoleTemplatesFormRowHelpText"
|
||||
defaultMessage="Create templates that describe the roles to assign to your users."
|
||||
/>{' '}
|
||||
<EuiLink
|
||||
href={documentationLinks.getRoleMappingTemplateDocUrl()}
|
||||
external={true}
|
||||
target="_blank"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingRoleTemplatesFormRowLearnMore"
|
||||
defaultMessage="Learn about role templates"
|
||||
/>
|
||||
</EuiLink>
|
||||
</span>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiLink
|
||||
onClick={() => {
|
||||
this.onRolesModeChange('roles');
|
||||
}}
|
||||
data-test-subj="switchToRolesButton"
|
||||
>
|
||||
<Fragment>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.switchToRoles"
|
||||
defaultMessage="Switch to roles"
|
||||
/>{' '}
|
||||
<EuiIcon size="s" type="inputOutput" />
|
||||
</Fragment>
|
||||
</EuiLink>
|
||||
</EuiText>
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<EuiFormRow fullWidth={true} {...validationFunction()}>
|
||||
<RoleSelector
|
||||
roleMapping={this.props.roleMapping}
|
||||
mode={this.state.rolesMode}
|
||||
canUseInlineScripts={this.props.canUseInlineScripts}
|
||||
canUseStoredScripts={this.props.canUseStoredScripts}
|
||||
onChange={roleMapping => this.props.onChange(roleMapping)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
);
|
||||
};
|
||||
|
||||
private getEnabledSwitch = () => {
|
||||
return (
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingEnabledFormRowTitle"
|
||||
defaultMessage="Enable mapping"
|
||||
/>
|
||||
</h3>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingEnabledFormRowHelpText"
|
||||
defaultMessage="Map roles to users based on their username, groups, and other metadata. When false, ignore mappings."
|
||||
/>
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingEnabledFormRowLabel"
|
||||
defaultMessage="Enable mapping"
|
||||
/>
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<EuiSwitch
|
||||
name={'enabled'}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingEnabledLabel"
|
||||
defaultMessage="Enable mapping"
|
||||
/>
|
||||
}
|
||||
showLabel={false}
|
||||
data-test-subj="roleMappingsEnabledSwitch"
|
||||
checked={this.props.roleMapping.enabled}
|
||||
onChange={e => {
|
||||
this.props.onChange({
|
||||
...this.props.roleMapping,
|
||||
enabled: e.target.checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
);
|
||||
};
|
||||
|
||||
private onNameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const name = e.target.value;
|
||||
|
||||
this.props.onChange({
|
||||
...this.props.roleMapping,
|
||||
name,
|
||||
});
|
||||
};
|
||||
|
||||
private onRolesModeChange = (rolesMode: State['rolesMode']) => {
|
||||
const canUseTemplates = this.props.canUseInlineScripts || this.props.canUseStoredScripts;
|
||||
if (rolesMode === 'templates' && canUseTemplates) {
|
||||
// Create blank template as a starting point
|
||||
const defaultTemplate = this.props.canUseInlineScripts
|
||||
? {
|
||||
template: { source: '' },
|
||||
}
|
||||
: {
|
||||
template: { id: '' },
|
||||
};
|
||||
this.props.onChange({
|
||||
...this.props.roleMapping,
|
||||
roles: [],
|
||||
role_templates: [defaultTemplate],
|
||||
});
|
||||
}
|
||||
this.setState({ rolesMode });
|
||||
};
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { AddRoleTemplateButton } from './add_role_template_button';
|
||||
|
||||
describe('AddRoleTemplateButton', () => {
|
||||
it('renders a warning instead of a button if all script types are disabled', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<AddRoleTemplateButton
|
||||
onClick={jest.fn()}
|
||||
canUseInlineScripts={false}
|
||||
canUseStoredScripts={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="Role templates unavailable"
|
||||
id="xpack.security.management.editRoleMapping.roleTemplatesUnavailableTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="Role templates cannot be used when scripts are disabled in Elasticsearch."
|
||||
id="xpack.security.management.editRoleMapping.roleTemplatesUnavailable"
|
||||
values={Object {}}
|
||||
/>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
|
||||
it(`asks for an inline template to be created if both script types are enabled`, () => {
|
||||
const onClickHandler = jest.fn();
|
||||
const wrapper = mountWithIntl(
|
||||
<AddRoleTemplateButton
|
||||
onClick={onClickHandler}
|
||||
canUseInlineScripts={true}
|
||||
canUseStoredScripts={true}
|
||||
/>
|
||||
);
|
||||
wrapper.simulate('click');
|
||||
expect(onClickHandler).toHaveBeenCalledTimes(1);
|
||||
expect(onClickHandler).toHaveBeenCalledWith('inline');
|
||||
});
|
||||
|
||||
it(`asks for a stored template to be created if inline scripts are disabled`, () => {
|
||||
const onClickHandler = jest.fn();
|
||||
const wrapper = mountWithIntl(
|
||||
<AddRoleTemplateButton
|
||||
onClick={onClickHandler}
|
||||
canUseInlineScripts={false}
|
||||
canUseStoredScripts={true}
|
||||
/>
|
||||
);
|
||||
wrapper.simulate('click');
|
||||
expect(onClickHandler).toHaveBeenCalledTimes(1);
|
||||
expect(onClickHandler).toHaveBeenCalledWith('stored');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
interface Props {
|
||||
canUseStoredScripts: boolean;
|
||||
canUseInlineScripts: boolean;
|
||||
onClick: (templateType: 'inline' | 'stored') => void;
|
||||
}
|
||||
|
||||
export const AddRoleTemplateButton = (props: Props) => {
|
||||
if (!props.canUseStoredScripts && !props.canUseInlineScripts) {
|
||||
return (
|
||||
<EuiCallOut
|
||||
iconType="alert"
|
||||
color="danger"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleTemplatesUnavailableTitle"
|
||||
defaultMessage="Role templates unavailable"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleTemplatesUnavailable"
|
||||
defaultMessage="Role templates cannot be used when scripts are disabled in Elasticsearch."
|
||||
/>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
|
||||
const addRoleTemplate = (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.addRoleTemplate"
|
||||
defaultMessage="Add template"
|
||||
/>
|
||||
);
|
||||
if (props.canUseInlineScripts) {
|
||||
return (
|
||||
<EuiButtonEmpty
|
||||
iconType="plusInCircle"
|
||||
onClick={() => props.onClick('inline')}
|
||||
data-test-subj="addRoleTemplateButton"
|
||||
>
|
||||
{addRoleTemplate}
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiButtonEmpty
|
||||
iconType="plusInCircle"
|
||||
onClick={() => props.onClick('stored')}
|
||||
data-test-subj="addRoleTemplateButton"
|
||||
>
|
||||
{addRoleTemplate}
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
};
|
|
@ -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 { RoleSelector } from './role_selector';
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
import { EuiComboBox } from '@elastic/eui';
|
||||
import { RoleSelector } from './role_selector';
|
||||
import { RoleMapping } from '../../../../../../../common/model';
|
||||
import { RoleTemplateEditor } from './role_template_editor';
|
||||
import { AddRoleTemplateButton } from './add_role_template_button';
|
||||
|
||||
jest.mock('../../../../../../lib/roles_api', () => {
|
||||
return {
|
||||
RolesApi: {
|
||||
getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('RoleSelector', () => {
|
||||
it('allows roles to be selected, removing any previously selected role templates', () => {
|
||||
const props = {
|
||||
roleMapping: {
|
||||
roles: [] as string[],
|
||||
role_templates: [
|
||||
{
|
||||
template: { source: '' },
|
||||
},
|
||||
],
|
||||
} as RoleMapping,
|
||||
canUseStoredScripts: true,
|
||||
canUseInlineScripts: true,
|
||||
onChange: jest.fn(),
|
||||
mode: 'roles',
|
||||
} as RoleSelector['props'];
|
||||
|
||||
const wrapper = mountWithIntl(<RoleSelector {...props} />);
|
||||
(wrapper.find(EuiComboBox).props() as any).onChange([{ label: 'foo_role' }]);
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith({
|
||||
roles: ['foo_role'],
|
||||
role_templates: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows role templates to be created, removing any previously selected roles', () => {
|
||||
const props = {
|
||||
roleMapping: {
|
||||
roles: ['foo_role'],
|
||||
role_templates: [] as any,
|
||||
} as RoleMapping,
|
||||
canUseStoredScripts: true,
|
||||
canUseInlineScripts: true,
|
||||
onChange: jest.fn(),
|
||||
mode: 'templates',
|
||||
} as RoleSelector['props'];
|
||||
|
||||
const wrapper = mountWithIntl(<RoleSelector {...props} />);
|
||||
|
||||
wrapper.find(AddRoleTemplateButton).simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith({
|
||||
roles: [],
|
||||
role_templates: [
|
||||
{
|
||||
template: { source: '' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows role templates to be edited', () => {
|
||||
const props = {
|
||||
roleMapping: {
|
||||
roles: [] as string[],
|
||||
role_templates: [
|
||||
{
|
||||
template: { source: 'foo_role' },
|
||||
},
|
||||
],
|
||||
} as RoleMapping,
|
||||
canUseStoredScripts: true,
|
||||
canUseInlineScripts: true,
|
||||
onChange: jest.fn(),
|
||||
mode: 'templates',
|
||||
} as RoleSelector['props'];
|
||||
|
||||
const wrapper = mountWithIntl(<RoleSelector {...props} />);
|
||||
|
||||
wrapper
|
||||
.find(RoleTemplateEditor)
|
||||
.props()
|
||||
.onChange({
|
||||
template: { source: '{{username}}_role' },
|
||||
});
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith({
|
||||
roles: [],
|
||||
role_templates: [
|
||||
{
|
||||
template: { source: '{{username}}_role' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows role templates to be deleted', () => {
|
||||
const props = {
|
||||
roleMapping: {
|
||||
roles: [] as string[],
|
||||
role_templates: [
|
||||
{
|
||||
template: { source: 'foo_role' },
|
||||
},
|
||||
],
|
||||
} as RoleMapping,
|
||||
canUseStoredScripts: true,
|
||||
canUseInlineScripts: true,
|
||||
onChange: jest.fn(),
|
||||
mode: 'templates',
|
||||
} as RoleSelector['props'];
|
||||
|
||||
const wrapper = mountWithIntl(<RoleSelector {...props} />);
|
||||
|
||||
findTestSubject(wrapper, 'deleteRoleTemplateButton').simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith({
|
||||
roles: [],
|
||||
role_templates: [],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* 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 React, { Fragment } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiComboBox, EuiFormRow, EuiHorizontalRule } from '@elastic/eui';
|
||||
import { RoleMapping, Role } from '../../../../../../../common/model';
|
||||
import { RolesApi } from '../../../../../../lib/roles_api';
|
||||
import { AddRoleTemplateButton } from './add_role_template_button';
|
||||
import { RoleTemplateEditor } from './role_template_editor';
|
||||
|
||||
interface Props {
|
||||
roleMapping: RoleMapping;
|
||||
canUseInlineScripts: boolean;
|
||||
canUseStoredScripts: boolean;
|
||||
mode: 'roles' | 'templates';
|
||||
onChange: (roleMapping: RoleMapping) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
export class RoleSelector extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = { roles: [] };
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
const roles = await RolesApi.getRoles();
|
||||
this.setState({ roles });
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { mode } = this.props;
|
||||
return (
|
||||
<EuiFormRow fullWidth>
|
||||
{mode === 'roles' ? this.getRoleComboBox() : this.getRoleTemplates()}
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
|
||||
private getRoleComboBox = () => {
|
||||
const { roles = [] } = this.props.roleMapping;
|
||||
return (
|
||||
<EuiComboBox
|
||||
data-test-subj="roleMappingFormRoleComboBox"
|
||||
placeholder={i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.selectRolesPlaceholder',
|
||||
{ defaultMessage: 'Select one or more roles' }
|
||||
)}
|
||||
isLoading={this.state.roles.length === 0}
|
||||
options={this.state.roles.map(r => ({ label: r.name }))}
|
||||
selectedOptions={roles!.map(r => ({ label: r }))}
|
||||
onChange={selectedOptions => {
|
||||
this.props.onChange({
|
||||
...this.props.roleMapping,
|
||||
roles: selectedOptions.map(so => so.label),
|
||||
role_templates: [],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private getRoleTemplates = () => {
|
||||
const { role_templates: roleTemplates = [] } = this.props.roleMapping;
|
||||
return (
|
||||
<div>
|
||||
{roleTemplates.map((rt, index) => (
|
||||
<Fragment key={index}>
|
||||
<RoleTemplateEditor
|
||||
canUseStoredScripts={this.props.canUseStoredScripts}
|
||||
canUseInlineScripts={this.props.canUseInlineScripts}
|
||||
roleTemplate={rt}
|
||||
onChange={updatedTemplate => {
|
||||
const templates = [...(this.props.roleMapping.role_templates || [])];
|
||||
templates.splice(index, 1, updatedTemplate);
|
||||
this.props.onChange({
|
||||
...this.props.roleMapping,
|
||||
role_templates: templates,
|
||||
});
|
||||
}}
|
||||
onDelete={() => {
|
||||
const templates = [...(this.props.roleMapping.role_templates || [])];
|
||||
templates.splice(index, 1);
|
||||
this.props.onChange({
|
||||
...this.props.roleMapping,
|
||||
role_templates: templates,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<EuiHorizontalRule />
|
||||
</Fragment>
|
||||
))}
|
||||
<AddRoleTemplateButton
|
||||
canUseStoredScripts={this.props.canUseStoredScripts}
|
||||
canUseInlineScripts={this.props.canUseInlineScripts}
|
||||
onClick={type => {
|
||||
switch (type) {
|
||||
case 'inline': {
|
||||
const templates = this.props.roleMapping.role_templates || [];
|
||||
this.props.onChange({
|
||||
...this.props.roleMapping,
|
||||
roles: [],
|
||||
role_templates: [...templates, { template: { source: '' } }],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'stored': {
|
||||
const templates = this.props.roleMapping.role_templates || [];
|
||||
this.props.onChange({
|
||||
...this.props.roleMapping,
|
||||
roles: [],
|
||||
role_templates: [...templates, { template: { id: '' } }],
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported template type: ${type}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { RoleTemplateEditor } from './role_template_editor';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
|
||||
describe('RoleTemplateEditor', () => {
|
||||
it('allows inline templates to be edited', () => {
|
||||
const props = {
|
||||
roleTemplate: {
|
||||
template: {
|
||||
source: '{{username}}_foo',
|
||||
},
|
||||
},
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
canUseStoredScripts: true,
|
||||
canUseInlineScripts: true,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<RoleTemplateEditor {...props} />);
|
||||
(wrapper
|
||||
.find('EuiFieldText[data-test-subj="roleTemplateSourceEditor"]')
|
||||
.props() as any).onChange({ target: { value: 'new_script' } });
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith({
|
||||
template: {
|
||||
source: 'new_script',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('warns when editing inline scripts when they are disabled', () => {
|
||||
const props = {
|
||||
roleTemplate: {
|
||||
template: {
|
||||
source: '{{username}}_foo',
|
||||
},
|
||||
},
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
canUseStoredScripts: true,
|
||||
canUseInlineScripts: false,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<RoleTemplateEditor {...props} />);
|
||||
expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(1);
|
||||
expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0);
|
||||
expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns when editing stored scripts when they are disabled', () => {
|
||||
const props = {
|
||||
roleTemplate: {
|
||||
template: {
|
||||
id: '{{username}}_foo',
|
||||
},
|
||||
},
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
canUseStoredScripts: false,
|
||||
canUseInlineScripts: true,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<RoleTemplateEditor {...props} />);
|
||||
expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0);
|
||||
expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(1);
|
||||
expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('allows template types to be changed', () => {
|
||||
const props = {
|
||||
roleTemplate: {
|
||||
template: {
|
||||
source: '{{username}}_foo',
|
||||
},
|
||||
},
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
canUseStoredScripts: true,
|
||||
canUseInlineScripts: true,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<RoleTemplateEditor {...props} />);
|
||||
(wrapper
|
||||
.find('EuiComboBox[data-test-subj="roleMappingsFormTemplateType"]')
|
||||
.props() as any).onChange('stored');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith({
|
||||
template: {
|
||||
id: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('warns when an invalid role template is specified', () => {
|
||||
const props = {
|
||||
roleTemplate: {
|
||||
template: `This is a string instead of an object if the template was stored in an unparsable format in ES`,
|
||||
},
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
canUseStoredScripts: true,
|
||||
canUseInlineScripts: true,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<RoleTemplateEditor {...props} />);
|
||||
expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(1);
|
||||
expect(findTestSubject(wrapper, 'roleTemplateSourceEditor')).toHaveLength(0);
|
||||
expect(findTestSubject(wrapper, 'roleTemplateScriptIdEditor')).toHaveLength(0);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,254 @@
|
|||
/*
|
||||
* 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 React, { Fragment } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiFieldText,
|
||||
EuiCallOut,
|
||||
EuiText,
|
||||
EuiSwitch,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { RoleTemplate } from '../../../../../../../common/model';
|
||||
import {
|
||||
isInlineRoleTemplate,
|
||||
isStoredRoleTemplate,
|
||||
isInvalidRoleTemplate,
|
||||
} from '../../services/role_template_type';
|
||||
import { RoleTemplateTypeSelect } from './role_template_type_select';
|
||||
|
||||
interface Props {
|
||||
roleTemplate: RoleTemplate;
|
||||
canUseInlineScripts: boolean;
|
||||
canUseStoredScripts: boolean;
|
||||
onChange: (roleTemplate: RoleTemplate) => void;
|
||||
onDelete: (roleTemplate: RoleTemplate) => void;
|
||||
}
|
||||
|
||||
export const RoleTemplateEditor = ({
|
||||
roleTemplate,
|
||||
onChange,
|
||||
onDelete,
|
||||
canUseInlineScripts,
|
||||
canUseStoredScripts,
|
||||
}: Props) => {
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
{getTemplateConfigurationFields()}
|
||||
{getEditorForTemplate()}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
iconType="trash"
|
||||
color="danger"
|
||||
size="xs"
|
||||
onClick={() => onDelete(roleTemplate)}
|
||||
data-test-subj="deleteRoleTemplateButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.deleteRoleTemplateButton"
|
||||
defaultMessage="Delete template"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
||||
function getTemplateFormatSwitch() {
|
||||
const returnsJsonLabel = i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.roleTemplateReturnsJson',
|
||||
{
|
||||
defaultMessage: 'Returns JSON',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow label={returnsJsonLabel}>
|
||||
<EuiSwitch
|
||||
checked={roleTemplate.format === 'json'}
|
||||
label={returnsJsonLabel}
|
||||
showLabel={false}
|
||||
onChange={e => {
|
||||
onChange({
|
||||
...roleTemplate,
|
||||
format: e.target.checked ? 'json' : 'string',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
|
||||
function getTemplateConfigurationFields() {
|
||||
const templateTypeComboBox = (
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleTemplateType"
|
||||
defaultMessage="Template type"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<RoleTemplateTypeSelect
|
||||
roleTemplate={roleTemplate}
|
||||
canUseStoredScripts={canUseStoredScripts}
|
||||
canUseInlineScripts={canUseInlineScripts}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
|
||||
const templateFormatSwitch = <EuiFlexItem>{getTemplateFormatSwitch()}</EuiFlexItem>;
|
||||
|
||||
return (
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
{templateTypeComboBox}
|
||||
{templateFormatSwitch}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
||||
|
||||
function getEditorForTemplate() {
|
||||
if (isInlineRoleTemplate(roleTemplate)) {
|
||||
const extraProps: Record<string, any> = {};
|
||||
if (!canUseInlineScripts) {
|
||||
extraProps.isInvalid = true;
|
||||
extraProps.error = (
|
||||
<EuiText size="xs" color="danger" data-test-subj="roleMappingInlineScriptsDisabled">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleTemplateInlineScriptsDisabled"
|
||||
defaultMessage="Template uses inline scripts, which are disabled in Elasticsearch."
|
||||
/>
|
||||
</EuiText>
|
||||
);
|
||||
}
|
||||
const example = '{{username}}_role';
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiFlexItem grow={1} style={{ maxWidth: '400px' }}>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleTemplateLabel"
|
||||
defaultMessage="Template"
|
||||
/>
|
||||
}
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleTemplateHelpText"
|
||||
defaultMessage="Mustache templates are allowed. Example: {example}"
|
||||
values={{
|
||||
example,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{...extraProps}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="roleTemplateSourceEditor"
|
||||
value={roleTemplate.template.source}
|
||||
onChange={e => {
|
||||
onChange({
|
||||
...roleTemplate,
|
||||
template: {
|
||||
source: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (isStoredRoleTemplate(roleTemplate)) {
|
||||
const extraProps: Record<string, any> = {};
|
||||
if (!canUseStoredScripts) {
|
||||
extraProps.isInvalid = true;
|
||||
extraProps.error = (
|
||||
<EuiText size="xs" color="danger" data-test-subj="roleMappingStoredScriptsDisabled">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleTemplateStoredScriptsDisabled"
|
||||
defaultMessage="Template uses stored scripts, which are disabled in Elasticsearch."
|
||||
/>
|
||||
</EuiText>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiFlexItem grow={1} style={{ maxWidth: '400px' }}>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.storedScriptLabel"
|
||||
defaultMessage="Stored script ID"
|
||||
/>
|
||||
}
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.storedScriptHelpText"
|
||||
defaultMessage="ID of a previously stored Painless or Mustache script."
|
||||
/>
|
||||
}
|
||||
{...extraProps}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="roleTemplateScriptIdEditor"
|
||||
value={roleTemplate.template.id}
|
||||
onChange={e => {
|
||||
onChange({
|
||||
...roleTemplate,
|
||||
template: {
|
||||
id: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInvalidRoleTemplate(roleTemplate)) {
|
||||
return (
|
||||
<EuiFlexItem grow={1} data-test-subj="roleMappingInvalidRoleTemplate">
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.invalidRoleTemplateTitle"
|
||||
defaultMessage="Invalid role template"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.invalidRoleTemplateMessage"
|
||||
defaultMessage="Role template is invalid, and cannot be edited here. Please delete and recreate, or fix via the Role Mapping API."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unable to determine role template type`);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiComboBox } from '@elastic/eui';
|
||||
import { RoleTemplate } from '../../../../../../../common/model';
|
||||
import { isInlineRoleTemplate, isStoredRoleTemplate } from '../../services/role_template_type';
|
||||
|
||||
const templateTypeOptions = [
|
||||
{
|
||||
id: 'inline',
|
||||
label: i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.roleTemplate.inlineTypeLabel',
|
||||
{ defaultMessage: 'Role template' }
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'stored',
|
||||
label: i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.roleTemplate.storedTypeLabel',
|
||||
{ defaultMessage: 'Stored script' }
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
roleTemplate: RoleTemplate;
|
||||
onChange: (roleTempplate: RoleTemplate) => void;
|
||||
canUseStoredScripts: boolean;
|
||||
canUseInlineScripts: boolean;
|
||||
}
|
||||
|
||||
export const RoleTemplateTypeSelect = (props: Props) => {
|
||||
const availableOptions = templateTypeOptions.filter(
|
||||
({ id }) =>
|
||||
(id === 'inline' && props.canUseInlineScripts) ||
|
||||
(id === 'stored' && props.canUseStoredScripts)
|
||||
);
|
||||
|
||||
const selectedOptions = templateTypeOptions.filter(
|
||||
({ id }) =>
|
||||
(id === 'inline' && isInlineRoleTemplate(props.roleTemplate)) ||
|
||||
(id === 'stored' && isStoredRoleTemplate(props.roleTemplate))
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
options={availableOptions}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
selectedOptions={selectedOptions}
|
||||
data-test-subj="roleMappingsFormTemplateType"
|
||||
onChange={selected => {
|
||||
const [{ id }] = selected;
|
||||
if (id === 'inline') {
|
||||
props.onChange({
|
||||
...props.roleTemplate,
|
||||
template: {
|
||||
source: '',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
props.onChange({
|
||||
...props.roleTemplate,
|
||||
template: {
|
||||
id: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
isClearable={false}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
.secRoleMapping__ruleEditorGroup--even {
|
||||
background-color: $euiColorLightestShade;
|
||||
}
|
||||
|
||||
.secRoleMapping__ruleEditorGroup--odd {
|
||||
background-color: $euiColorEmptyShade;
|
||||
}
|
|
@ -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 React from 'react';
|
||||
import { AddRuleButton } from './add_rule_button';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
import { FieldRule, AllRule } from '../../../model';
|
||||
|
||||
describe('AddRuleButton', () => {
|
||||
it('allows a field rule to be created', () => {
|
||||
const props = {
|
||||
onClick: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<AddRuleButton {...props} />);
|
||||
findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
|
||||
expect(findTestSubject(wrapper, 'addRuleContextMenu')).toHaveLength(1);
|
||||
|
||||
// EUI renders this ID twice, so we need to target the button itself
|
||||
wrapper.find('button[id="addRuleOption"]').simulate('click');
|
||||
|
||||
expect(props.onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [newRule] = props.onClick.mock.calls[0];
|
||||
expect(newRule).toBeInstanceOf(FieldRule);
|
||||
expect(newRule.toRaw()).toEqual({
|
||||
field: { username: '*' },
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a rule group to be created', () => {
|
||||
const props = {
|
||||
onClick: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<AddRuleButton {...props} />);
|
||||
findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
|
||||
expect(findTestSubject(wrapper, 'addRuleContextMenu')).toHaveLength(1);
|
||||
|
||||
// EUI renders this ID twice, so we need to target the button itself
|
||||
wrapper.find('button[id="addRuleGroupOption"]').simulate('click');
|
||||
|
||||
expect(props.onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [newRule] = props.onClick.mock.calls[0];
|
||||
expect(newRule).toBeInstanceOf(AllRule);
|
||||
expect(newRule.toRaw()).toEqual({
|
||||
all: [{ field: { username: '*' } }],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* 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 React, { useState } from 'react';
|
||||
import { EuiButtonEmpty, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { Rule, FieldRule, AllRule } from '../../../model';
|
||||
|
||||
interface Props {
|
||||
onClick: (newRule: Rule) => void;
|
||||
}
|
||||
|
||||
export const AddRuleButton = (props: Props) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const button = (
|
||||
<EuiButtonEmpty
|
||||
iconType="plusInCircle"
|
||||
data-test-subj="roleMappingsAddRuleButton"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.addRuleButton"
|
||||
defaultMessage="Add"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
|
||||
const options = [
|
||||
<EuiContextMenuItem
|
||||
id="addRuleOption"
|
||||
key="rule"
|
||||
name="Add rule"
|
||||
icon="user"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
props.onClick(new FieldRule('username', '*'));
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.addRuleOption"
|
||||
defaultMessage="Add rule"
|
||||
/>
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
id="addRuleGroupOption"
|
||||
key="ruleGroup"
|
||||
name="Add rule group"
|
||||
icon="list"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
props.onClick(new AllRule([new FieldRule('username', '*')]));
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.addRuleGroupOption"
|
||||
defaultMessage="Add rule group"
|
||||
/>
|
||||
</EuiContextMenuItem>,
|
||||
];
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
id="addRuleContextMenu"
|
||||
data-test-subj="addRuleContextMenu"
|
||||
button={button}
|
||||
isOpen={isMenuOpen}
|
||||
closePopover={() => setIsMenuOpen(false)}
|
||||
panelPaddingSize="none"
|
||||
withTitle
|
||||
anchorPosition="downLeft"
|
||||
>
|
||||
<EuiContextMenuPanel title="Add rule" items={options} />
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,230 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { FieldRuleEditor } from './field_rule_editor';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { FieldRule } from '../../../model';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
|
||||
function assertField(wrapper: ReactWrapper<any, any, any>, index: number, field: string) {
|
||||
const isFirst = index === 0;
|
||||
if (isFirst) {
|
||||
expect(
|
||||
wrapper.find(`EuiComboBox[data-test-subj~="fieldRuleEditorField-${index}"]`).props()
|
||||
).toMatchObject({
|
||||
selectedOptions: [{ label: field }],
|
||||
});
|
||||
|
||||
expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-combo`)).toHaveLength(1);
|
||||
expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-expression`)).toHaveLength(0);
|
||||
} else {
|
||||
expect(
|
||||
wrapper.find(`EuiExpression[data-test-subj~="fieldRuleEditorField-${index}"]`).props()
|
||||
).toMatchObject({
|
||||
value: field,
|
||||
});
|
||||
|
||||
expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-combo`)).toHaveLength(0);
|
||||
expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-expression`)).toHaveLength(1);
|
||||
}
|
||||
}
|
||||
|
||||
function assertValueType(wrapper: ReactWrapper<any, any, any>, index: number, type: string) {
|
||||
const valueTypeField = findTestSubject(wrapper, `fieldRuleEditorValueType-${index}`);
|
||||
expect(valueTypeField.props()).toMatchObject({ value: type });
|
||||
}
|
||||
|
||||
function assertValue(wrapper: ReactWrapper<any, any, any>, index: number, value: any) {
|
||||
const valueField = findTestSubject(wrapper, `fieldRuleEditorValue-${index}`);
|
||||
expect(valueField.props()).toMatchObject({ value });
|
||||
}
|
||||
|
||||
describe('FieldRuleEditor', () => {
|
||||
it('can render a text-based field rule', () => {
|
||||
const props = {
|
||||
rule: new FieldRule('username', '*'),
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<FieldRuleEditor {...props} />);
|
||||
assertField(wrapper, 0, 'username');
|
||||
assertValueType(wrapper, 0, 'text');
|
||||
assertValue(wrapper, 0, '*');
|
||||
});
|
||||
|
||||
it('can render a number-based field rule', () => {
|
||||
const props = {
|
||||
rule: new FieldRule('username', 12),
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<FieldRuleEditor {...props} />);
|
||||
assertField(wrapper, 0, 'username');
|
||||
assertValueType(wrapper, 0, 'number');
|
||||
assertValue(wrapper, 0, 12);
|
||||
});
|
||||
|
||||
it('can render a null-based field rule', () => {
|
||||
const props = {
|
||||
rule: new FieldRule('username', null),
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<FieldRuleEditor {...props} />);
|
||||
assertField(wrapper, 0, 'username');
|
||||
assertValueType(wrapper, 0, 'null');
|
||||
assertValue(wrapper, 0, '-- null --');
|
||||
});
|
||||
|
||||
it('can render a boolean-based field rule (true)', () => {
|
||||
const props = {
|
||||
rule: new FieldRule('username', true),
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<FieldRuleEditor {...props} />);
|
||||
assertField(wrapper, 0, 'username');
|
||||
assertValueType(wrapper, 0, 'boolean');
|
||||
assertValue(wrapper, 0, 'true');
|
||||
});
|
||||
|
||||
it('can render a boolean-based field rule (false)', () => {
|
||||
const props = {
|
||||
rule: new FieldRule('username', false),
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<FieldRuleEditor {...props} />);
|
||||
assertField(wrapper, 0, 'username');
|
||||
assertValueType(wrapper, 0, 'boolean');
|
||||
assertValue(wrapper, 0, 'false');
|
||||
});
|
||||
|
||||
it('can render with alternate values specified', () => {
|
||||
const props = {
|
||||
rule: new FieldRule('username', ['*', 12, null, true, false]),
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<FieldRuleEditor {...props} />);
|
||||
expect(findTestSubject(wrapper, 'addAlternateValueButton')).toHaveLength(1);
|
||||
|
||||
assertField(wrapper, 0, 'username');
|
||||
assertValueType(wrapper, 0, 'text');
|
||||
assertValue(wrapper, 0, '*');
|
||||
|
||||
assertField(wrapper, 1, 'username');
|
||||
assertValueType(wrapper, 1, 'number');
|
||||
assertValue(wrapper, 1, 12);
|
||||
|
||||
assertField(wrapper, 2, 'username');
|
||||
assertValueType(wrapper, 2, 'null');
|
||||
assertValue(wrapper, 2, '-- null --');
|
||||
|
||||
assertField(wrapper, 3, 'username');
|
||||
assertValueType(wrapper, 3, 'boolean');
|
||||
assertValue(wrapper, 3, 'true');
|
||||
|
||||
assertField(wrapper, 4, 'username');
|
||||
assertValueType(wrapper, 4, 'boolean');
|
||||
assertValue(wrapper, 4, 'false');
|
||||
});
|
||||
|
||||
it('allows alternate values to be added when "allowAdd" is set to true', () => {
|
||||
const props = {
|
||||
rule: new FieldRule('username', null),
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<FieldRuleEditor {...props} />);
|
||||
findTestSubject(wrapper, 'addAlternateValueButton').simulate('click');
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
const [updatedRule] = props.onChange.mock.calls[0];
|
||||
expect(updatedRule.toRaw()).toEqual({
|
||||
field: {
|
||||
username: [null, '*'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('allows values to be deleted; deleting all values invokes "onDelete"', () => {
|
||||
const props = {
|
||||
rule: new FieldRule('username', ['*', 12, null]),
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<FieldRuleEditor {...props} />);
|
||||
|
||||
expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(3);
|
||||
findTestSubject(wrapper, `fieldRuleEditorDeleteValue-0`).simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
const [updatedRule1] = props.onChange.mock.calls[0];
|
||||
expect(updatedRule1.toRaw()).toEqual({
|
||||
field: {
|
||||
username: [12, null],
|
||||
},
|
||||
});
|
||||
|
||||
props.onChange.mockReset();
|
||||
|
||||
// simulate updated rule being fed back in
|
||||
wrapper.setProps({ rule: updatedRule1 });
|
||||
|
||||
expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(2);
|
||||
findTestSubject(wrapper, `fieldRuleEditorDeleteValue-1`).simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
const [updatedRule2] = props.onChange.mock.calls[0];
|
||||
expect(updatedRule2.toRaw()).toEqual({
|
||||
field: {
|
||||
username: [12],
|
||||
},
|
||||
});
|
||||
|
||||
props.onChange.mockReset();
|
||||
|
||||
// simulate updated rule being fed back in
|
||||
wrapper.setProps({ rule: updatedRule2 });
|
||||
|
||||
expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(1);
|
||||
findTestSubject(wrapper, `fieldRuleEditorDeleteValue-0`).simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(0);
|
||||
expect(props.onDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows field data types to be changed', () => {
|
||||
const props = {
|
||||
rule: new FieldRule('username', '*'),
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<FieldRuleEditor {...props} />);
|
||||
|
||||
const { onChange } = findTestSubject(wrapper, `fieldRuleEditorValueType-0`).props();
|
||||
onChange!({ target: { value: 'number' } as any } as any);
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
const [updatedRule] = props.onChange.mock.calls[0];
|
||||
expect(updatedRule.toRaw()).toEqual({
|
||||
field: {
|
||||
username: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,380 @@
|
|||
/*
|
||||
* 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 React, { Component, ChangeEvent } from 'react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiExpression,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiFieldText,
|
||||
EuiComboBox,
|
||||
EuiSelect,
|
||||
EuiFieldNumber,
|
||||
EuiIcon,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FieldRule, FieldRuleValue } from '../../../model';
|
||||
|
||||
interface Props {
|
||||
rule: FieldRule;
|
||||
onChange: (rule: FieldRule) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const userFields = [
|
||||
{
|
||||
name: 'username',
|
||||
},
|
||||
{
|
||||
name: 'dn',
|
||||
},
|
||||
{
|
||||
name: 'groups',
|
||||
},
|
||||
{
|
||||
name: 'realm',
|
||||
},
|
||||
];
|
||||
|
||||
const fieldOptions = userFields.map(f => ({ label: f.name }));
|
||||
|
||||
type ComparisonOption = 'text' | 'number' | 'null' | 'boolean';
|
||||
const comparisonOptions: Record<
|
||||
ComparisonOption,
|
||||
{ id: ComparisonOption; defaultValue: FieldRuleValue }
|
||||
> = {
|
||||
text: {
|
||||
id: 'text',
|
||||
defaultValue: '*',
|
||||
},
|
||||
number: {
|
||||
id: 'number',
|
||||
defaultValue: 0,
|
||||
},
|
||||
null: {
|
||||
id: 'null',
|
||||
defaultValue: null,
|
||||
},
|
||||
boolean: {
|
||||
id: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
};
|
||||
|
||||
export class FieldRuleEditor extends Component<Props, {}> {
|
||||
public render() {
|
||||
const { field, value } = this.props.rule;
|
||||
|
||||
const content = Array.isArray(value)
|
||||
? value.map((v, index) => this.renderFieldRow(field, value, index))
|
||||
: [this.renderFieldRow(field, value, 0)];
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column">
|
||||
{content.map((row, index) => {
|
||||
return <EuiFlexItem key={index}>{row}</EuiFlexItem>;
|
||||
})}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
private renderFieldRow = (field: string, ruleValue: FieldRuleValue, valueIndex: number) => {
|
||||
const isPrimaryRow = valueIndex === 0;
|
||||
|
||||
let renderAddValueButton = true;
|
||||
let rowRuleValue: FieldRuleValue = ruleValue;
|
||||
if (Array.isArray(ruleValue)) {
|
||||
renderAddValueButton = ruleValue.length - 1 === valueIndex;
|
||||
rowRuleValue = ruleValue[valueIndex];
|
||||
}
|
||||
|
||||
const comparisonType = this.getComparisonType(rowRuleValue);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={1}>
|
||||
{isPrimaryRow ? (
|
||||
<EuiFormRow
|
||||
label={i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.fieldRuleEditor.userFieldLabel',
|
||||
{ defaultMessage: 'User field' }
|
||||
)}
|
||||
>
|
||||
<EuiComboBox
|
||||
isClearable={false}
|
||||
selectedOptions={[{ label: field }]}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
onChange={this.onFieldChange}
|
||||
onCreateOption={this.onAddField}
|
||||
options={fieldOptions}
|
||||
data-test-subj={`fieldRuleEditorField-${valueIndex} fieldRuleEditorField-${valueIndex}-combo`}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
) : (
|
||||
<EuiFormRow hasEmptyLabelSpace={true}>
|
||||
<EuiExpression
|
||||
description={i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.fieldRuleEditor.orLabel',
|
||||
{ defaultMessage: 'or' }
|
||||
)}
|
||||
value={field}
|
||||
data-test-subj={`fieldRuleEditorField-${valueIndex} fieldRuleEditorField-${valueIndex}-expression`}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{this.renderFieldTypeInput(comparisonType.id, valueIndex)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={1}>
|
||||
{this.renderFieldValueInput(comparisonType.id, rowRuleValue, valueIndex)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFormRow hasEmptyLabelSpace={true}>
|
||||
{renderAddValueButton ? (
|
||||
<EuiButtonIcon
|
||||
iconSize="s"
|
||||
iconType="plusInCircle"
|
||||
onClick={this.onAddAlternateValue}
|
||||
color="primary"
|
||||
data-test-subj="addAlternateValueButton"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.fieldRuleEditor.addAlternateValueButton',
|
||||
{
|
||||
defaultMessage: 'Add alternate value',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<EuiIcon size="l" type="empty" aria-hidden={true} />
|
||||
)}
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiFormRow hasEmptyLabelSpace={true}>
|
||||
<EuiButtonIcon
|
||||
iconType="trash"
|
||||
color="danger"
|
||||
iconSize="s"
|
||||
data-test-subj={`fieldRuleEditorDeleteValue fieldRuleEditorDeleteValue-${valueIndex}`}
|
||||
aria-label={i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.fieldRuleEditor.deleteValueLabel',
|
||||
{
|
||||
defaultMessage: 'Delete value',
|
||||
}
|
||||
)}
|
||||
onClick={() => this.onRemoveAlternateValue(valueIndex)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
private renderFieldTypeInput = (inputType: ComparisonOption, valueIndex: number) => {
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.fieldRuleEditor.typeFormRow',
|
||||
{
|
||||
defaultMessage: 'Type',
|
||||
}
|
||||
)}
|
||||
key={valueIndex}
|
||||
>
|
||||
<EuiSelect
|
||||
options={[
|
||||
{ value: 'text', text: 'text' },
|
||||
{ value: 'number', text: 'number' },
|
||||
{ value: 'null', text: 'is null' },
|
||||
{ value: 'boolean', text: 'boolean' },
|
||||
]}
|
||||
data-test-subj={`fieldRuleEditorValueType-${valueIndex}`}
|
||||
value={inputType}
|
||||
onChange={e =>
|
||||
this.onComparisonTypeChange(valueIndex, e.target.value as ComparisonOption)
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
||||
|
||||
private renderFieldValueInput = (
|
||||
fieldType: ComparisonOption,
|
||||
rowRuleValue: FieldRuleValue,
|
||||
valueIndex: number
|
||||
) => {
|
||||
const inputField = this.getInputFieldForType(fieldType, rowRuleValue, valueIndex);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.fieldRuleEditor.valueFormRow',
|
||||
{
|
||||
defaultMessage: 'Value',
|
||||
}
|
||||
)}
|
||||
key={valueIndex}
|
||||
>
|
||||
{inputField}
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
||||
|
||||
private getInputFieldForType = (
|
||||
fieldType: ComparisonOption,
|
||||
rowRuleValue: FieldRuleValue,
|
||||
valueIndex: number
|
||||
) => {
|
||||
const isNullValue = rowRuleValue === null;
|
||||
|
||||
const commonProps = {
|
||||
'data-test-subj': `fieldRuleEditorValue-${valueIndex}`,
|
||||
};
|
||||
|
||||
switch (fieldType) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<EuiSelect
|
||||
{...commonProps}
|
||||
value={rowRuleValue?.toString()}
|
||||
onChange={this.onBooleanValueChange(valueIndex)}
|
||||
options={[
|
||||
{ value: 'true', text: 'true' },
|
||||
{ value: 'false', text: 'false' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
case 'null':
|
||||
return (
|
||||
<EuiFieldText
|
||||
{...commonProps}
|
||||
value={isNullValue ? '-- null --' : (rowRuleValue as string)}
|
||||
onChange={this.onValueChange(valueIndex)}
|
||||
disabled={isNullValue}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<EuiFieldNumber
|
||||
data-test-subj={`fieldRuleEditorValue-${valueIndex}`}
|
||||
value={rowRuleValue as number}
|
||||
onChange={this.onNumericValueChange(valueIndex)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unsupported input field type: ${fieldType}`);
|
||||
}
|
||||
};
|
||||
|
||||
private onAddAlternateValue = () => {
|
||||
const { field, value } = this.props.rule;
|
||||
const nextValue = Array.isArray(value) ? [...value] : [value];
|
||||
nextValue.push('*');
|
||||
this.props.onChange(new FieldRule(field, nextValue));
|
||||
};
|
||||
|
||||
private onRemoveAlternateValue = (index: number) => {
|
||||
const { field, value } = this.props.rule;
|
||||
|
||||
if (!Array.isArray(value) || value.length === 1) {
|
||||
// Only one value left. Delete entire rule instead.
|
||||
this.props.onDelete();
|
||||
return;
|
||||
}
|
||||
const nextValue = [...value];
|
||||
nextValue.splice(index, 1);
|
||||
this.props.onChange(new FieldRule(field, nextValue));
|
||||
};
|
||||
|
||||
private onFieldChange = ([newField]: Array<{ label: string }>) => {
|
||||
if (!newField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { value } = this.props.rule;
|
||||
this.props.onChange(new FieldRule(newField.label, value));
|
||||
};
|
||||
|
||||
private onAddField = (newField: string) => {
|
||||
const { value } = this.props.rule;
|
||||
this.props.onChange(new FieldRule(newField, value));
|
||||
};
|
||||
|
||||
private onValueChange = (index: number) => (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { field, value } = this.props.rule;
|
||||
let nextValue;
|
||||
if (Array.isArray(value)) {
|
||||
nextValue = [...value];
|
||||
nextValue.splice(index, 1, e.target.value);
|
||||
} else {
|
||||
nextValue = e.target.value;
|
||||
}
|
||||
this.props.onChange(new FieldRule(field, nextValue));
|
||||
};
|
||||
|
||||
private onNumericValueChange = (index: number) => (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { field, value } = this.props.rule;
|
||||
let nextValue;
|
||||
if (Array.isArray(value)) {
|
||||
nextValue = [...value];
|
||||
nextValue.splice(index, 1, parseFloat(e.target.value));
|
||||
} else {
|
||||
nextValue = parseFloat(e.target.value);
|
||||
}
|
||||
this.props.onChange(new FieldRule(field, nextValue));
|
||||
};
|
||||
|
||||
private onBooleanValueChange = (index: number) => (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const boolValue = e.target.value === 'true';
|
||||
|
||||
const { field, value } = this.props.rule;
|
||||
let nextValue;
|
||||
if (Array.isArray(value)) {
|
||||
nextValue = [...value];
|
||||
nextValue.splice(index, 1, boolValue);
|
||||
} else {
|
||||
nextValue = boolValue;
|
||||
}
|
||||
this.props.onChange(new FieldRule(field, nextValue));
|
||||
};
|
||||
|
||||
private onComparisonTypeChange = (index: number, newType: ComparisonOption) => {
|
||||
const comparison = comparisonOptions[newType];
|
||||
if (!comparison) {
|
||||
throw new Error(`Unexpected comparison type: ${newType}`);
|
||||
}
|
||||
const { field, value } = this.props.rule;
|
||||
let nextValue = value;
|
||||
if (Array.isArray(value)) {
|
||||
nextValue = [...value];
|
||||
nextValue.splice(index, 1, comparison.defaultValue as any);
|
||||
} else {
|
||||
nextValue = comparison.defaultValue;
|
||||
}
|
||||
this.props.onChange(new FieldRule(field, nextValue));
|
||||
};
|
||||
|
||||
private getComparisonType(ruleValue: FieldRuleValue) {
|
||||
const valueType = typeof ruleValue;
|
||||
if (valueType === 'string' || valueType === 'undefined') {
|
||||
return comparisonOptions.text;
|
||||
}
|
||||
if (valueType === 'number') {
|
||||
return comparisonOptions.number;
|
||||
}
|
||||
if (valueType === 'boolean') {
|
||||
return comparisonOptions.boolean;
|
||||
}
|
||||
if (ruleValue === null) {
|
||||
return comparisonOptions.null;
|
||||
}
|
||||
throw new Error(`Unable to detect comparison type for rule value [${ruleValue}]`);
|
||||
}
|
||||
}
|
|
@ -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 { RuleEditorPanel } from './rule_editor_panel';
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* 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 'brace';
|
||||
import 'brace/mode/json';
|
||||
|
||||
// brace/ace uses the Worker class, which is not currently provided by JSDOM.
|
||||
// This is not required for the tests to pass, but it rather suppresses lengthy
|
||||
// warnings in the console which adds unnecessary noise to the test output.
|
||||
import 'test_utils/stub_web_worker';
|
||||
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { JSONRuleEditor } from './json_rule_editor';
|
||||
import { EuiCodeEditor } from '@elastic/eui';
|
||||
import { AllRule, AnyRule, FieldRule, ExceptAnyRule, ExceptAllRule } from '../../../model';
|
||||
|
||||
describe('JSONRuleEditor', () => {
|
||||
it('renders an empty rule set', () => {
|
||||
const props = {
|
||||
rules: null,
|
||||
onChange: jest.fn(),
|
||||
onValidityChange: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<JSONRuleEditor {...props} />);
|
||||
|
||||
expect(props.onChange).not.toHaveBeenCalled();
|
||||
expect(props.onValidityChange).not.toHaveBeenCalled();
|
||||
|
||||
expect(wrapper.find(EuiCodeEditor).props().value).toMatchInlineSnapshot(`"{}"`);
|
||||
});
|
||||
|
||||
it('renders a rule set', () => {
|
||||
const props = {
|
||||
rules: new AllRule([
|
||||
new AnyRule([new FieldRule('username', '*')]),
|
||||
new ExceptAnyRule([
|
||||
new FieldRule('metadata.foo.bar', '*'),
|
||||
new AllRule([new FieldRule('realm', 'special-one')]),
|
||||
]),
|
||||
new ExceptAllRule([new FieldRule('realm', '*')]),
|
||||
]),
|
||||
onChange: jest.fn(),
|
||||
onValidityChange: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<JSONRuleEditor {...props} />);
|
||||
|
||||
const { value } = wrapper.find(EuiCodeEditor).props();
|
||||
expect(JSON.parse(value)).toEqual({
|
||||
all: [
|
||||
{
|
||||
any: [{ field: { username: '*' } }],
|
||||
},
|
||||
{
|
||||
except: {
|
||||
any: [
|
||||
{ field: { 'metadata.foo.bar': '*' } },
|
||||
{
|
||||
all: [{ field: { realm: 'special-one' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
except: {
|
||||
all: [{ field: { realm: '*' } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('notifies when input contains invalid JSON', () => {
|
||||
const props = {
|
||||
rules: null,
|
||||
onChange: jest.fn(),
|
||||
onValidityChange: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<JSONRuleEditor {...props} />);
|
||||
|
||||
const allRule = JSON.stringify(new AllRule().toRaw());
|
||||
act(() => {
|
||||
wrapper
|
||||
.find(EuiCodeEditor)
|
||||
.props()
|
||||
.onChange(allRule + ', this makes invalid JSON');
|
||||
});
|
||||
|
||||
expect(props.onValidityChange).toHaveBeenCalledTimes(1);
|
||||
expect(props.onValidityChange).toHaveBeenCalledWith(false);
|
||||
expect(props.onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('notifies when input contains an invalid rule set, even if it is valid JSON', () => {
|
||||
const props = {
|
||||
rules: null,
|
||||
onChange: jest.fn(),
|
||||
onValidityChange: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<JSONRuleEditor {...props} />);
|
||||
|
||||
const invalidRule = JSON.stringify({
|
||||
all: [
|
||||
{
|
||||
field: {
|
||||
foo: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper
|
||||
.find(EuiCodeEditor)
|
||||
.props()
|
||||
.onChange(invalidRule);
|
||||
});
|
||||
|
||||
expect(props.onValidityChange).toHaveBeenCalledTimes(1);
|
||||
expect(props.onValidityChange).toHaveBeenCalledWith(false);
|
||||
expect(props.onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires onChange when a valid rule set is provided after being previously invalidated', () => {
|
||||
const props = {
|
||||
rules: null,
|
||||
onChange: jest.fn(),
|
||||
onValidityChange: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<JSONRuleEditor {...props} />);
|
||||
|
||||
const allRule = JSON.stringify(new AllRule().toRaw());
|
||||
act(() => {
|
||||
wrapper
|
||||
.find(EuiCodeEditor)
|
||||
.props()
|
||||
.onChange(allRule + ', this makes invalid JSON');
|
||||
});
|
||||
|
||||
expect(props.onValidityChange).toHaveBeenCalledTimes(1);
|
||||
expect(props.onValidityChange).toHaveBeenCalledWith(false);
|
||||
expect(props.onChange).not.toHaveBeenCalled();
|
||||
|
||||
props.onValidityChange.mockReset();
|
||||
|
||||
act(() => {
|
||||
wrapper
|
||||
.find(EuiCodeEditor)
|
||||
.props()
|
||||
.onChange(allRule);
|
||||
});
|
||||
|
||||
expect(props.onValidityChange).toHaveBeenCalledTimes(1);
|
||||
expect(props.onValidityChange).toHaveBeenCalledWith(true);
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
const [updatedRule] = props.onChange.mock.calls[0];
|
||||
expect(JSON.stringify(updatedRule.toRaw())).toEqual(allRule);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* 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 React, { useState, Fragment } from 'react';
|
||||
|
||||
import 'brace/mode/json';
|
||||
import 'brace/theme/github';
|
||||
import { EuiCodeEditor, EuiFormRow, EuiButton, EuiSpacer, EuiLink, EuiText } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Rule, RuleBuilderError, generateRulesFromRaw } from '../../../model';
|
||||
import { documentationLinks } from '../../../services/documentation_links';
|
||||
|
||||
interface Props {
|
||||
rules: Rule | null;
|
||||
onChange: (updatedRules: Rule | null) => void;
|
||||
onValidityChange: (isValid: boolean) => void;
|
||||
}
|
||||
|
||||
export const JSONRuleEditor = (props: Props) => {
|
||||
const [rawRules, setRawRules] = useState(
|
||||
JSON.stringify(props.rules ? props.rules.toRaw() : {}, null, 2)
|
||||
);
|
||||
|
||||
const [ruleBuilderError, setRuleBuilderError] = useState<RuleBuilderError | null>(null);
|
||||
|
||||
function onRulesChange(updatedRules: string) {
|
||||
setRawRules(updatedRules);
|
||||
// Fire onChange only if rules are valid
|
||||
try {
|
||||
const ruleJSON = JSON.parse(updatedRules);
|
||||
props.onChange(generateRulesFromRaw(ruleJSON).rules);
|
||||
props.onValidityChange(true);
|
||||
setRuleBuilderError(null);
|
||||
} catch (e) {
|
||||
if (e instanceof RuleBuilderError) {
|
||||
setRuleBuilderError(e);
|
||||
} else {
|
||||
setRuleBuilderError(null);
|
||||
}
|
||||
props.onValidityChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
function reformatRules() {
|
||||
try {
|
||||
const ruleJSON = JSON.parse(rawRules);
|
||||
setRawRules(JSON.stringify(ruleJSON, null, 2));
|
||||
} catch (ignore) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
isInvalid={Boolean(ruleBuilderError)}
|
||||
error={
|
||||
ruleBuilderError &&
|
||||
i18n.translate('xpack.security.management.editRoleMapping.JSONEditorRuleError', {
|
||||
defaultMessage: 'Invalid rule definition at {ruleLocation}: {errorMessage}',
|
||||
values: {
|
||||
ruleLocation: ruleBuilderError.ruleTrace.join('.'),
|
||||
errorMessage: ruleBuilderError.message,
|
||||
},
|
||||
})
|
||||
}
|
||||
fullWidth
|
||||
data-test-subj="roleMappingsJSONEditor"
|
||||
>
|
||||
<Fragment>
|
||||
<EuiCodeEditor
|
||||
aria-label={''}
|
||||
mode={'json'}
|
||||
theme="github"
|
||||
value={rawRules}
|
||||
onChange={onRulesChange}
|
||||
width="100%"
|
||||
height="auto"
|
||||
minLines={6}
|
||||
maxLines={30}
|
||||
isReadOnly={false}
|
||||
setOptions={{
|
||||
showLineNumbers: true,
|
||||
tabSize: 2,
|
||||
}}
|
||||
editorProps={{
|
||||
$blockScrolling: Infinity,
|
||||
}}
|
||||
showGutter={true}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiButton iconType="broom" onClick={reformatRules} size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.autoFormatRuleText"
|
||||
defaultMessage="Reformat"
|
||||
/>
|
||||
</EuiButton>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiText size="s">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.JSONEditorHelpText"
|
||||
defaultMessage="Specify your rules in JSON format consistent with the {roleMappingAPI}"
|
||||
values={{
|
||||
roleMappingAPI: (
|
||||
<EuiLink
|
||||
href={documentationLinks.getRoleMappingAPIDocUrl()}
|
||||
external={true}
|
||||
target="_blank"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.JSONEditorEsApi"
|
||||
defaultMessage="Elasticsearch role mapping API."
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</Fragment>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { RuleEditorPanel } from '.';
|
||||
import { VisualRuleEditor } from './visual_rule_editor';
|
||||
import { JSONRuleEditor } from './json_rule_editor';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
|
||||
// brace/ace uses the Worker class, which is not currently provided by JSDOM.
|
||||
// This is not required for the tests to pass, but it rather suppresses lengthy
|
||||
// warnings in the console which adds unnecessary noise to the test output.
|
||||
import 'test_utils/stub_web_worker';
|
||||
import { AllRule, FieldRule } from '../../../model';
|
||||
import { EuiErrorBoundary } from '@elastic/eui';
|
||||
|
||||
describe('RuleEditorPanel', () => {
|
||||
it('renders the visual editor when no rules are defined', () => {
|
||||
const props = {
|
||||
rawRules: {},
|
||||
onChange: jest.fn(),
|
||||
onValidityChange: jest.fn(),
|
||||
validateForm: false,
|
||||
};
|
||||
const wrapper = mountWithIntl(<RuleEditorPanel {...props} />);
|
||||
expect(wrapper.find(VisualRuleEditor)).toHaveLength(1);
|
||||
expect(wrapper.find(JSONRuleEditor)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('allows switching to the JSON editor, carrying over rules', () => {
|
||||
const props = {
|
||||
rawRules: {
|
||||
all: [
|
||||
{
|
||||
field: {
|
||||
username: ['*'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
onChange: jest.fn(),
|
||||
onValidityChange: jest.fn(),
|
||||
validateForm: false,
|
||||
};
|
||||
const wrapper = mountWithIntl(<RuleEditorPanel {...props} />);
|
||||
expect(wrapper.find(VisualRuleEditor)).toHaveLength(1);
|
||||
expect(wrapper.find(JSONRuleEditor)).toHaveLength(0);
|
||||
|
||||
findTestSubject(wrapper, 'roleMappingsJSONRuleEditorButton').simulate('click');
|
||||
|
||||
expect(wrapper.find(VisualRuleEditor)).toHaveLength(0);
|
||||
|
||||
const jsonEditor = wrapper.find(JSONRuleEditor);
|
||||
expect(jsonEditor).toHaveLength(1);
|
||||
const { rules } = jsonEditor.props();
|
||||
expect(rules!.toRaw()).toEqual(props.rawRules);
|
||||
});
|
||||
|
||||
it('allows switching to the visual editor, carrying over rules', () => {
|
||||
const props = {
|
||||
rawRules: {
|
||||
field: { username: '*' },
|
||||
},
|
||||
onChange: jest.fn(),
|
||||
onValidityChange: jest.fn(),
|
||||
validateForm: false,
|
||||
};
|
||||
const wrapper = mountWithIntl(<RuleEditorPanel {...props} />);
|
||||
|
||||
findTestSubject(wrapper, 'roleMappingsJSONRuleEditorButton').simulate('click');
|
||||
|
||||
expect(wrapper.find(VisualRuleEditor)).toHaveLength(0);
|
||||
expect(wrapper.find(JSONRuleEditor)).toHaveLength(1);
|
||||
|
||||
const jsonEditor = wrapper.find(JSONRuleEditor);
|
||||
expect(jsonEditor).toHaveLength(1);
|
||||
const { rules: initialRules, onChange } = jsonEditor.props();
|
||||
expect(initialRules?.toRaw()).toEqual({
|
||||
field: { username: '*' },
|
||||
});
|
||||
|
||||
onChange(new AllRule([new FieldRule('otherRule', 12)]));
|
||||
|
||||
findTestSubject(wrapper, 'roleMappingsVisualRuleEditorButton').simulate('click');
|
||||
|
||||
expect(wrapper.find(VisualRuleEditor)).toHaveLength(1);
|
||||
expect(wrapper.find(JSONRuleEditor)).toHaveLength(0);
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
const [rules] = props.onChange.mock.calls[0];
|
||||
expect(rules).toEqual({
|
||||
all: [{ field: { otherRule: 12 } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('catches errors thrown by child components', () => {
|
||||
const props = {
|
||||
rawRules: {},
|
||||
onChange: jest.fn(),
|
||||
onValidityChange: jest.fn(),
|
||||
validateForm: false,
|
||||
};
|
||||
const wrapper = mountWithIntl(<RuleEditorPanel {...props} />);
|
||||
|
||||
wrapper.find(VisualRuleEditor).simulateError(new Error('Something awful happened here.'));
|
||||
|
||||
expect(wrapper.find(VisualRuleEditor)).toHaveLength(0);
|
||||
expect(wrapper.find(EuiErrorBoundary)).toHaveLength(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,298 @@
|
|||
/*
|
||||
* 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 React, { Component, Fragment } from 'react';
|
||||
import {
|
||||
EuiSpacer,
|
||||
EuiConfirmModal,
|
||||
EuiOverlayMask,
|
||||
EuiCallOut,
|
||||
EuiErrorBoundary,
|
||||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiText,
|
||||
EuiFormRow,
|
||||
EuiPanel,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { RoleMapping } from '../../../../../../../common/model';
|
||||
import { VisualRuleEditor } from './visual_rule_editor';
|
||||
import { JSONRuleEditor } from './json_rule_editor';
|
||||
import { VISUAL_MAX_RULE_DEPTH } from '../../services/role_mapping_constants';
|
||||
import { Rule, generateRulesFromRaw } from '../../../model';
|
||||
import { validateRoleMappingRules } from '../../services/role_mapping_validation';
|
||||
import { documentationLinks } from '../../../services/documentation_links';
|
||||
|
||||
interface Props {
|
||||
rawRules: RoleMapping['rules'];
|
||||
onChange: (rawRules: RoleMapping['rules']) => void;
|
||||
onValidityChange: (isValid: boolean) => void;
|
||||
validateForm: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
rules: Rule | null;
|
||||
maxDepth: number;
|
||||
isRuleValid: boolean;
|
||||
showConfirmModeChange: boolean;
|
||||
showVisualEditorDisabledAlert: boolean;
|
||||
mode: 'visual' | 'json';
|
||||
}
|
||||
|
||||
export class RuleEditorPanel extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...this.initializeFromRawRules(props.rawRules),
|
||||
isRuleValid: true,
|
||||
showConfirmModeChange: false,
|
||||
showVisualEditorDisabledAlert: false,
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const validationResult =
|
||||
this.props.validateForm &&
|
||||
validateRoleMappingRules({ rules: this.state.rules ? this.state.rules.toRaw() : {} });
|
||||
|
||||
let validationWarning = null;
|
||||
if (validationResult && validationResult.error) {
|
||||
validationWarning = (
|
||||
<Fragment>
|
||||
<EuiCallOut color="danger" title={validationResult.error} size="s" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPanel>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.mappingRulesPanelTitle"
|
||||
defaultMessage="Mapping rules"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<EuiText size="s" color="subdued">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.roleMappingRulesFormRowHelpText"
|
||||
defaultMessage="Assign roles to users who match these rules. {learnMoreLink}"
|
||||
values={{
|
||||
learnMoreLink: (
|
||||
<EuiLink
|
||||
href={documentationLinks.getRoleMappingFieldRulesDocUrl()}
|
||||
target="_blank"
|
||||
external={true}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.fieldRuleEditor.fieldValueHelp"
|
||||
defaultMessage="Learn about supported field values."
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth isInvalid={validationResult && validationResult.isInvalid}>
|
||||
<EuiErrorBoundary>
|
||||
<Fragment>
|
||||
{validationWarning}
|
||||
{this.getEditor()}
|
||||
<EuiSpacer size="xl" />
|
||||
{this.getModeToggle()}
|
||||
{this.getConfirmModeChangePrompt()}
|
||||
</Fragment>
|
||||
</EuiErrorBoundary>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
|
||||
private initializeFromRawRules = (rawRules: Props['rawRules']) => {
|
||||
const { rules, maxDepth } = generateRulesFromRaw(rawRules);
|
||||
const mode: State['mode'] = maxDepth >= VISUAL_MAX_RULE_DEPTH ? 'json' : 'visual';
|
||||
return {
|
||||
rules,
|
||||
mode,
|
||||
maxDepth,
|
||||
};
|
||||
};
|
||||
|
||||
private getModeToggle() {
|
||||
if (this.state.mode === 'json' && this.state.maxDepth > VISUAL_MAX_RULE_DEPTH) {
|
||||
return (
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
title={i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.visualEditorUnavailableTitle',
|
||||
{ defaultMessage: 'Visual editor unavailable' }
|
||||
)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.visualEditorUnavailableMessage"
|
||||
defaultMessage="Rule definition is too complex for the visual editor."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't offer swith if no rules are present yet
|
||||
if (this.state.mode === 'visual' && this.state.rules === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (this.state.mode) {
|
||||
case 'visual':
|
||||
return (
|
||||
<EuiLink
|
||||
data-test-subj="roleMappingsJSONRuleEditorButton"
|
||||
onClick={() => {
|
||||
this.trySwitchEditorMode('json');
|
||||
}}
|
||||
>
|
||||
<Fragment>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.switchToJSONEditorLink"
|
||||
defaultMessage="Switch to JSON editor"
|
||||
/>{' '}
|
||||
<EuiIcon type="inputOutput" size="s" />
|
||||
</Fragment>
|
||||
</EuiLink>
|
||||
);
|
||||
case 'json':
|
||||
return (
|
||||
<EuiLink
|
||||
data-test-subj="roleMappingsVisualRuleEditorButton"
|
||||
onClick={() => {
|
||||
this.trySwitchEditorMode('visual');
|
||||
}}
|
||||
>
|
||||
<Fragment>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.switchToVisualEditorLink"
|
||||
defaultMessage="Switch to visual editor"
|
||||
/>{' '}
|
||||
<EuiIcon type="inputOutput" size="s" />
|
||||
</Fragment>
|
||||
</EuiLink>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unexpected rule editor mode: ${this.state.mode}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getEditor() {
|
||||
switch (this.state.mode) {
|
||||
case 'visual':
|
||||
return (
|
||||
<VisualRuleEditor
|
||||
rules={this.state.rules}
|
||||
maxDepth={this.state.maxDepth}
|
||||
onChange={this.onRuleChange}
|
||||
onSwitchEditorMode={() => this.trySwitchEditorMode('json')}
|
||||
/>
|
||||
);
|
||||
case 'json':
|
||||
return (
|
||||
<JSONRuleEditor
|
||||
rules={this.state.rules}
|
||||
onChange={this.onRuleChange}
|
||||
onValidityChange={this.onValidityChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unexpected rule editor mode: ${this.state.mode}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getConfirmModeChangePrompt = () => {
|
||||
if (!this.state.showConfirmModeChange) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<EuiOverlayMask>
|
||||
<EuiConfirmModal
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.confirmModeChangePromptTitle"
|
||||
defaultMessage="Switch with invalid rules?"
|
||||
/>
|
||||
}
|
||||
onCancel={() => this.setState({ showConfirmModeChange: false })}
|
||||
onConfirm={() => {
|
||||
this.setState({ mode: 'visual', showConfirmModeChange: false });
|
||||
this.onValidityChange(true);
|
||||
}}
|
||||
cancelButtonText={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.confirmModeChangePromptCancelButton"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
}
|
||||
confirmButtonText={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.confirmModeChangePromptConfirmButton"
|
||||
defaultMessage="Switch anyway"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.confirmModeChangePromptBody"
|
||||
defaultMessage="The rules defined are not valid, and cannot be translated to the visual editor. You may lose some or all of your changes during the conversion. Do you wish to continue?"
|
||||
/>
|
||||
</p>
|
||||
</EuiConfirmModal>
|
||||
</EuiOverlayMask>
|
||||
);
|
||||
};
|
||||
|
||||
private onRuleChange = (updatedRule: Rule | null) => {
|
||||
const raw = updatedRule ? updatedRule.toRaw() : {};
|
||||
this.props.onChange(raw);
|
||||
this.setState({
|
||||
...generateRulesFromRaw(raw),
|
||||
});
|
||||
};
|
||||
|
||||
private onValidityChange = (isRuleValid: boolean) => {
|
||||
this.setState({ isRuleValid });
|
||||
this.props.onValidityChange(isRuleValid);
|
||||
};
|
||||
|
||||
private trySwitchEditorMode = (newMode: State['mode']) => {
|
||||
switch (newMode) {
|
||||
case 'visual': {
|
||||
if (this.state.isRuleValid) {
|
||||
this.setState({ mode: newMode });
|
||||
this.onValidityChange(true);
|
||||
} else {
|
||||
this.setState({ showConfirmModeChange: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'json':
|
||||
this.setState({ mode: newMode });
|
||||
this.onValidityChange(true);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected rule editor mode: ${this.state.mode}`);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { RuleGroupEditor } from './rule_group_editor';
|
||||
import { shallowWithIntl, mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
|
||||
import { AllRule, FieldRule, AnyRule, ExceptAnyRule } from '../../../model';
|
||||
import { FieldRuleEditor } from './field_rule_editor';
|
||||
import { AddRuleButton } from './add_rule_button';
|
||||
import { EuiContextMenuItem } from '@elastic/eui';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
|
||||
describe('RuleGroupEditor', () => {
|
||||
it('renders an empty group', () => {
|
||||
const props = {
|
||||
rule: new AllRule([]),
|
||||
allowAdd: true,
|
||||
ruleDepth: 0,
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
const wrapper = shallowWithIntl(<RuleGroupEditor {...props} />);
|
||||
expect(wrapper.find(RuleGroupEditor)).toHaveLength(0);
|
||||
expect(wrapper.find(FieldRuleEditor)).toHaveLength(0);
|
||||
expect(wrapper.find(AddRuleButton)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows the group type to be changed, maintaining child rules', async () => {
|
||||
const props = {
|
||||
rule: new AllRule([new FieldRule('username', '*')]),
|
||||
allowAdd: true,
|
||||
ruleDepth: 0,
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<RuleGroupEditor {...props} />);
|
||||
expect(wrapper.find(RuleGroupEditor)).toHaveLength(1);
|
||||
expect(wrapper.find(FieldRuleEditor)).toHaveLength(1);
|
||||
expect(wrapper.find(AddRuleButton)).toHaveLength(1);
|
||||
expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(1);
|
||||
|
||||
const anyRule = new AnyRule();
|
||||
|
||||
findTestSubject(wrapper, 'ruleGroupTitle').simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => {
|
||||
return menuItem.text() === anyRule.getDisplayTitle();
|
||||
});
|
||||
|
||||
anyRuleOption.simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
const [newRule] = props.onChange.mock.calls[0];
|
||||
expect(newRule).toBeInstanceOf(AnyRule);
|
||||
expect(newRule.toRaw()).toEqual(new AnyRule([new FieldRule('username', '*')]).toRaw());
|
||||
});
|
||||
|
||||
it('warns when changing group types which would invalidate child rules', async () => {
|
||||
const props = {
|
||||
rule: new AllRule([new ExceptAnyRule([new FieldRule('my_custom_field', 'foo*')])]),
|
||||
allowAdd: true,
|
||||
ruleDepth: 0,
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<RuleGroupEditor {...props} />);
|
||||
expect(wrapper.find(RuleGroupEditor)).toHaveLength(2);
|
||||
expect(wrapper.find(FieldRuleEditor)).toHaveLength(1);
|
||||
expect(wrapper.find(AddRuleButton)).toHaveLength(2);
|
||||
expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(2);
|
||||
|
||||
const anyRule = new AnyRule();
|
||||
|
||||
findTestSubject(wrapper, 'ruleGroupTitle')
|
||||
.first()
|
||||
.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => {
|
||||
return menuItem.text() === anyRule.getDisplayTitle();
|
||||
});
|
||||
|
||||
anyRuleOption.simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(0);
|
||||
expect(findTestSubject(wrapper, 'confirmRuleChangeModal')).toHaveLength(1);
|
||||
findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
const [newRule] = props.onChange.mock.calls[0];
|
||||
expect(newRule).toBeInstanceOf(AnyRule);
|
||||
|
||||
// new rule should a defaulted field sub rule, as the existing rules are not valid for the new type
|
||||
expect(newRule.toRaw()).toEqual(new AnyRule([new FieldRule('username', '*')]).toRaw());
|
||||
});
|
||||
|
||||
it('does not change groups when canceling the confirmation', async () => {
|
||||
const props = {
|
||||
rule: new AllRule([new ExceptAnyRule([new FieldRule('username', '*')])]),
|
||||
allowAdd: true,
|
||||
ruleDepth: 0,
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<RuleGroupEditor {...props} />);
|
||||
expect(wrapper.find(RuleGroupEditor)).toHaveLength(2);
|
||||
expect(wrapper.find(FieldRuleEditor)).toHaveLength(1);
|
||||
expect(wrapper.find(AddRuleButton)).toHaveLength(2);
|
||||
expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(2);
|
||||
|
||||
const anyRule = new AnyRule();
|
||||
|
||||
findTestSubject(wrapper, 'ruleGroupTitle')
|
||||
.first()
|
||||
.simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => {
|
||||
return menuItem.text() === anyRule.getDisplayTitle();
|
||||
});
|
||||
|
||||
anyRuleOption.simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(0);
|
||||
expect(findTestSubject(wrapper, 'confirmRuleChangeModal')).toHaveLength(1);
|
||||
findTestSubject(wrapper, 'confirmModalCancelButton').simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('hides the add rule button when instructed to', () => {
|
||||
const props = {
|
||||
rule: new AllRule([]),
|
||||
allowAdd: false,
|
||||
ruleDepth: 0,
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
};
|
||||
const wrapper = shallowWithIntl(<RuleGroupEditor {...props} />);
|
||||
expect(wrapper.find(AddRuleButton)).toHaveLength(0);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* 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 React, { Component, Fragment } from 'react';
|
||||
import {
|
||||
EuiPanel,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiButtonEmpty,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { AddRuleButton } from './add_rule_button';
|
||||
import { RuleGroupTitle } from './rule_group_title';
|
||||
import { FieldRuleEditor } from './field_rule_editor';
|
||||
import { RuleGroup, Rule, FieldRule } from '../../../model';
|
||||
import { isRuleGroup } from '../../services/is_rule_group';
|
||||
|
||||
interface Props {
|
||||
rule: RuleGroup;
|
||||
allowAdd: boolean;
|
||||
parentRule?: RuleGroup;
|
||||
ruleDepth: number;
|
||||
onChange: (rule: RuleGroup) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
export class RuleGroupEditor extends Component<Props, {}> {
|
||||
public render() {
|
||||
return (
|
||||
<EuiPanel
|
||||
className={`secRoleMapping__ruleEditorGroup--${this.props.ruleDepth % 2 ? 'odd' : 'even'}`}
|
||||
>
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup alignItems="center">
|
||||
<EuiFlexItem grow={true}>
|
||||
<RuleGroupTitle
|
||||
rule={this.props.rule}
|
||||
onChange={this.props.onChange}
|
||||
parentRule={this.props.parentRule}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
color="danger"
|
||||
onClick={this.props.onDelete}
|
||||
size="s"
|
||||
iconType="trash"
|
||||
data-test-subj="deleteRuleGroupButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.deleteRuleGroupButton"
|
||||
defaultMessage="Delete"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
{this.renderSubRules()}
|
||||
{this.props.allowAdd && (
|
||||
<EuiFlexItem>
|
||||
<AddRuleButton onClick={this.onAddRuleClick} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
|
||||
private renderSubRules = () => {
|
||||
return this.props.rule.getRules().map((subRule, subRuleIndex, rules) => {
|
||||
const isLastRule = subRuleIndex === rules.length - 1;
|
||||
const divider = isLastRule ? null : (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiHorizontalRule margin="m" />
|
||||
</EuiFlexItem>
|
||||
);
|
||||
|
||||
if (isRuleGroup(subRule)) {
|
||||
return (
|
||||
<Fragment key={subRuleIndex}>
|
||||
<EuiFlexItem>
|
||||
<RuleGroupEditor
|
||||
rule={subRule as RuleGroup}
|
||||
parentRule={this.props.rule}
|
||||
allowAdd={this.props.allowAdd}
|
||||
ruleDepth={this.props.ruleDepth + 1}
|
||||
onChange={updatedSubRule => {
|
||||
const updatedRule = this.props.rule.clone() as RuleGroup;
|
||||
updatedRule.replaceRule(subRuleIndex, updatedSubRule);
|
||||
this.props.onChange(updatedRule);
|
||||
}}
|
||||
onDelete={() => {
|
||||
const updatedRule = this.props.rule.clone() as RuleGroup;
|
||||
updatedRule.removeRule(subRuleIndex);
|
||||
this.props.onChange(updatedRule);
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{divider}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={subRuleIndex}>
|
||||
<EuiFlexItem>
|
||||
<FieldRuleEditor
|
||||
rule={subRule as FieldRule}
|
||||
onChange={updatedSubRule => {
|
||||
const updatedRule = this.props.rule.clone() as RuleGroup;
|
||||
updatedRule.replaceRule(subRuleIndex, updatedSubRule);
|
||||
this.props.onChange(updatedRule);
|
||||
}}
|
||||
onDelete={() => {
|
||||
const updatedRule = this.props.rule.clone() as RuleGroup;
|
||||
updatedRule.removeRule(subRuleIndex);
|
||||
this.props.onChange(updatedRule);
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{divider}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
private onAddRuleClick = (newRule: Rule) => {
|
||||
const updatedRule = this.props.rule.clone() as RuleGroup;
|
||||
updatedRule.addRule(newRule);
|
||||
this.props.onChange(updatedRule);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* 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 React, { useState } from 'react';
|
||||
import {
|
||||
EuiPopover,
|
||||
EuiContextMenuPanel,
|
||||
EuiContextMenuItem,
|
||||
EuiLink,
|
||||
EuiIcon,
|
||||
EuiOverlayMask,
|
||||
EuiConfirmModal,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import {
|
||||
RuleGroup,
|
||||
AllRule,
|
||||
AnyRule,
|
||||
ExceptAllRule,
|
||||
ExceptAnyRule,
|
||||
FieldRule,
|
||||
} from '../../../model';
|
||||
|
||||
interface Props {
|
||||
rule: RuleGroup;
|
||||
readonly?: boolean;
|
||||
parentRule?: RuleGroup;
|
||||
onChange: (rule: RuleGroup) => void;
|
||||
}
|
||||
|
||||
const rules = [new AllRule(), new AnyRule()];
|
||||
const exceptRules = [new ExceptAllRule(), new ExceptAnyRule()];
|
||||
|
||||
export const RuleGroupTitle = (props: Props) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const [showConfirmChangeModal, setShowConfirmChangeModal] = useState(false);
|
||||
const [pendingNewRule, setPendingNewRule] = useState<RuleGroup | null>(null);
|
||||
|
||||
const canUseExcept = props.parentRule && props.parentRule.canContainRules(exceptRules);
|
||||
|
||||
const availableRuleTypes = [...rules, ...(canUseExcept ? exceptRules : [])];
|
||||
|
||||
const onChange = (newRule: RuleGroup) => {
|
||||
const currentSubRules = props.rule.getRules();
|
||||
const areSubRulesValid = newRule.canContainRules(currentSubRules);
|
||||
if (areSubRulesValid) {
|
||||
const clone = newRule.clone() as RuleGroup;
|
||||
currentSubRules.forEach(subRule => clone.addRule(subRule));
|
||||
|
||||
props.onChange(clone);
|
||||
setIsMenuOpen(false);
|
||||
} else {
|
||||
setPendingNewRule(newRule);
|
||||
setShowConfirmChangeModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const changeRuleDiscardingSubRules = (newRule: RuleGroup) => {
|
||||
// Ensure a default sub rule is present when not carrying over the original sub rules
|
||||
const newRuleInstance = newRule.clone() as RuleGroup;
|
||||
if (newRuleInstance.getRules().length === 0) {
|
||||
newRuleInstance.addRule(new FieldRule('username', '*'));
|
||||
}
|
||||
|
||||
props.onChange(newRuleInstance);
|
||||
setIsMenuOpen(false);
|
||||
};
|
||||
|
||||
const ruleButton = (
|
||||
<EuiLink onClick={() => setIsMenuOpen(!isMenuOpen)} data-test-subj="ruleGroupTitle">
|
||||
{props.rule.getDisplayTitle()} <EuiIcon type="arrowDown" />
|
||||
</EuiLink>
|
||||
);
|
||||
|
||||
const ruleTypeSelector = (
|
||||
<EuiPopover button={ruleButton} isOpen={isMenuOpen} closePopover={() => setIsMenuOpen(false)}>
|
||||
<EuiContextMenuPanel
|
||||
items={availableRuleTypes.map((rt, index) => {
|
||||
const isSelected = rt.getDisplayTitle() === props.rule.getDisplayTitle();
|
||||
const icon = isSelected ? 'check' : 'empty';
|
||||
return (
|
||||
<EuiContextMenuItem key={index} icon={icon} onClick={() => onChange(rt as RuleGroup)}>
|
||||
{rt.getDisplayTitle()}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
</EuiPopover>
|
||||
);
|
||||
|
||||
const confirmChangeModal = showConfirmChangeModal ? (
|
||||
<EuiOverlayMask>
|
||||
<EuiConfirmModal
|
||||
data-test-subj="confirmRuleChangeModal"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.confirmGroupChangePromptTitle"
|
||||
defaultMessage="Change group type?"
|
||||
/>
|
||||
}
|
||||
onCancel={() => {
|
||||
setShowConfirmChangeModal(false);
|
||||
setPendingNewRule(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
setShowConfirmChangeModal(false);
|
||||
changeRuleDiscardingSubRules(pendingNewRule!);
|
||||
setPendingNewRule(null);
|
||||
}}
|
||||
cancelButtonText={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.confirmGroupChangeCancelButton"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
}
|
||||
confirmButtonText={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.confirmGroupChangeConfirmButton"
|
||||
defaultMessage="Change anyway"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.switchWithIncompatibleRulesMessage"
|
||||
defaultMessage="This group contains rules that are not compatible with the new type. If you change types, you will lose all rules within this group."
|
||||
/>
|
||||
</p>
|
||||
</EuiConfirmModal>
|
||||
</EuiOverlayMask>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<h3>
|
||||
{ruleTypeSelector}
|
||||
{confirmChangeModal}
|
||||
</h3>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { VisualRuleEditor } from './visual_rule_editor';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
import { AnyRule, AllRule, FieldRule, ExceptAnyRule, ExceptAllRule } from '../../../model';
|
||||
import { RuleGroupEditor } from './rule_group_editor';
|
||||
import { FieldRuleEditor } from './field_rule_editor';
|
||||
|
||||
describe('VisualRuleEditor', () => {
|
||||
it('renders an empty prompt when no rules are defined', () => {
|
||||
const props = {
|
||||
rules: null,
|
||||
maxDepth: 0,
|
||||
onSwitchEditorMode: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<VisualRuleEditor {...props} />);
|
||||
|
||||
findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
const [newRule] = props.onChange.mock.calls[0];
|
||||
expect(newRule.toRaw()).toEqual({
|
||||
all: [{ field: { username: '*' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a rule group when the "Add rules" button is clicked', () => {
|
||||
const props = {
|
||||
rules: null,
|
||||
maxDepth: 0,
|
||||
onSwitchEditorMode: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<VisualRuleEditor {...props} />);
|
||||
expect(findTestSubject(wrapper, 'roleMappingsNoRulesDefined')).toHaveLength(1);
|
||||
expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clicking the add button when no rules are defined populates an initial rule set', () => {
|
||||
const props = {
|
||||
rules: null,
|
||||
maxDepth: 0,
|
||||
onSwitchEditorMode: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<VisualRuleEditor {...props} />);
|
||||
findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
const [newRule] = props.onChange.mock.calls[0];
|
||||
expect(newRule).toBeInstanceOf(AllRule);
|
||||
expect(newRule.toRaw()).toEqual({
|
||||
all: [
|
||||
{
|
||||
field: {
|
||||
username: '*',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a nested rule set', () => {
|
||||
const props = {
|
||||
rules: new AllRule([
|
||||
new AnyRule([new FieldRule('username', '*')]),
|
||||
new ExceptAnyRule([
|
||||
new FieldRule('metadata.foo.bar', '*'),
|
||||
new AllRule([new FieldRule('realm', 'special-one')]),
|
||||
]),
|
||||
new ExceptAllRule([new FieldRule('realm', '*')]),
|
||||
]),
|
||||
maxDepth: 4,
|
||||
onSwitchEditorMode: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<VisualRuleEditor {...props} />);
|
||||
|
||||
expect(wrapper.find(RuleGroupEditor)).toHaveLength(5);
|
||||
expect(wrapper.find(FieldRuleEditor)).toHaveLength(4);
|
||||
expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns when the rule set is too complex', () => {
|
||||
const props = {
|
||||
rules: new AllRule([
|
||||
new AnyRule([
|
||||
new AllRule([
|
||||
new AnyRule([
|
||||
new AllRule([
|
||||
new AnyRule([
|
||||
new AllRule([
|
||||
new AnyRule([
|
||||
new AllRule([
|
||||
new AnyRule([
|
||||
new AllRule([
|
||||
new AnyRule([
|
||||
new AnyRule([
|
||||
new AllRule([new AnyRule([new FieldRule('username', '*')])]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
maxDepth: 11,
|
||||
onSwitchEditorMode: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
const wrapper = mountWithIntl(<VisualRuleEditor {...props} />);
|
||||
expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* 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 React, { Component, Fragment } from 'react';
|
||||
import { EuiEmptyPrompt, EuiCallOut, EuiSpacer, EuiButton } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { FieldRuleEditor } from './field_rule_editor';
|
||||
import { RuleGroupEditor } from './rule_group_editor';
|
||||
import { VISUAL_MAX_RULE_DEPTH } from '../../services/role_mapping_constants';
|
||||
import { Rule, FieldRule, RuleGroup, AllRule } from '../../../model';
|
||||
import { isRuleGroup } from '../../services/is_rule_group';
|
||||
|
||||
interface Props {
|
||||
rules: Rule | null;
|
||||
maxDepth: number;
|
||||
onChange: (rules: Rule | null) => void;
|
||||
onSwitchEditorMode: () => void;
|
||||
}
|
||||
|
||||
export class VisualRuleEditor extends Component<Props, {}> {
|
||||
public render() {
|
||||
if (this.props.rules) {
|
||||
const rules = this.renderRule(this.props.rules, this.onRuleChange);
|
||||
return (
|
||||
<Fragment>
|
||||
{this.getRuleDepthWarning()}
|
||||
{rules}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
title={
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.visualRuleEditor.noRulesDefinedTitle"
|
||||
defaultMessage="No rules defined"
|
||||
/>
|
||||
</h3>
|
||||
}
|
||||
titleSize="s"
|
||||
body={
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.visualRuleEditor.noRulesDefinedMessage"
|
||||
defaultMessage="Rules control which users should be assigned roles."
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
data-test-subj="roleMappingsNoRulesDefined"
|
||||
actions={
|
||||
<EuiButton
|
||||
color="primary"
|
||||
iconType="plusInCircle"
|
||||
data-test-subj="roleMappingsAddRuleButton"
|
||||
onClick={() => {
|
||||
this.props.onChange(new AllRule([new FieldRule('username', '*')]));
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.addFirstRuleButton"
|
||||
defaultMessage="Add rules"
|
||||
/>
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private canUseVisualEditor = () => this.props.maxDepth < VISUAL_MAX_RULE_DEPTH;
|
||||
|
||||
private getRuleDepthWarning = () => {
|
||||
if (this.canUseVisualEditor()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiCallOut
|
||||
iconType="alert"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.visualRuleEditor.switchToJSONEditorTitle"
|
||||
defaultMessage="Switch to JSON editor"
|
||||
/>
|
||||
}
|
||||
data-test-subj="roleMappingsRulesTooComplex"
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.visualRuleEditor.switchToJSONEditorMessage"
|
||||
defaultMessage="Role mapping rules are too complex for the visual editor. Switch to the JSON editor to continue editing this rule."
|
||||
/>
|
||||
</p>
|
||||
|
||||
<EuiButton onClick={this.props.onSwitchEditorMode} size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRoleMapping.visualRuleEditor.switchToJSONEditorButton"
|
||||
defaultMessage="Use JSON editor"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="s" />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
private onRuleChange = (updatedRule: Rule) => {
|
||||
this.props.onChange(updatedRule);
|
||||
};
|
||||
|
||||
private onRuleDelete = () => {
|
||||
this.props.onChange(null);
|
||||
};
|
||||
|
||||
private renderRule = (rule: Rule, onChange: (updatedRule: Rule) => void) => {
|
||||
return this.getEditorForRuleType(rule, onChange);
|
||||
};
|
||||
|
||||
private getEditorForRuleType(rule: Rule, onChange: (updatedRule: Rule) => void) {
|
||||
if (isRuleGroup(rule)) {
|
||||
return (
|
||||
<RuleGroupEditor
|
||||
rule={rule as RuleGroup}
|
||||
ruleDepth={0}
|
||||
allowAdd={this.canUseVisualEditor()}
|
||||
onChange={value => onChange(value)}
|
||||
onDelete={this.onRuleDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FieldRuleEditor
|
||||
rule={rule as FieldRule}
|
||||
onChange={value => onChange(value)}
|
||||
onDelete={this.onRuleDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
<kbn-management-app section="security/role_mappings" omit-breadcrumb-pages="['edit']">
|
||||
<div id="editRoleMappingReactRoot" />
|
||||
</kbn-management-app>
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { render, unmountComponentAtNode } from 'react-dom';
|
||||
import routes from 'ui/routes';
|
||||
import { I18nContext } from 'ui/i18n';
|
||||
import { npSetup } from 'ui/new_platform';
|
||||
import { RoleMappingsAPI } from '../../../../lib/role_mappings_api';
|
||||
// @ts-ignore
|
||||
import template from './edit_role_mapping.html';
|
||||
import { CREATE_ROLE_MAPPING_PATH } from '../../management_urls';
|
||||
import { getEditRoleMappingBreadcrumbs } from '../../breadcrumbs';
|
||||
import { EditRoleMappingPage } from './components';
|
||||
|
||||
routes.when(`${CREATE_ROLE_MAPPING_PATH}/:name?`, {
|
||||
template,
|
||||
k7Breadcrumbs: getEditRoleMappingBreadcrumbs,
|
||||
controller($scope, $route) {
|
||||
$scope.$$postDigest(() => {
|
||||
const domNode = document.getElementById('editRoleMappingReactRoot');
|
||||
|
||||
const { name } = $route.current.params;
|
||||
|
||||
render(
|
||||
<I18nContext>
|
||||
<EditRoleMappingPage
|
||||
name={name}
|
||||
roleMappingsAPI={new RoleMappingsAPI(npSetup.core.http)}
|
||||
/>
|
||||
</I18nContext>,
|
||||
domNode
|
||||
);
|
||||
|
||||
// unmount react on controller destroy
|
||||
$scope.$on('$destroy', () => {
|
||||
if (domNode) {
|
||||
unmountComponentAtNode(domNode);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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 { Rule, FieldRule } from '../../model';
|
||||
|
||||
export function isRuleGroup(rule: Rule) {
|
||||
return !(rule instanceof FieldRule);
|
||||
}
|
|
@ -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 VISUAL_MAX_RULE_DEPTH = 5;
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* 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 {
|
||||
validateRoleMappingName,
|
||||
validateRoleMappingRoles,
|
||||
validateRoleMappingRoleTemplates,
|
||||
validateRoleMappingRules,
|
||||
validateRoleMappingForSave,
|
||||
} from './role_mapping_validation';
|
||||
import { RoleMapping } from '../../../../../../common/model';
|
||||
|
||||
describe('validateRoleMappingName', () => {
|
||||
it('requires a value', () => {
|
||||
expect(validateRoleMappingName({ name: '' } as RoleMapping)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": "Name is required.",
|
||||
"isInvalid": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRoleMappingRoles', () => {
|
||||
it('requires a value', () => {
|
||||
expect(validateRoleMappingRoles(({ roles: [] } as unknown) as RoleMapping))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": "At least one role is required.",
|
||||
"isInvalid": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRoleMappingRoleTemplates', () => {
|
||||
it('requires a value', () => {
|
||||
expect(validateRoleMappingRoleTemplates(({ role_templates: [] } as unknown) as RoleMapping))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": "At least one role template is required.",
|
||||
"isInvalid": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRoleMappingRules', () => {
|
||||
it('requires at least one rule', () => {
|
||||
expect(validateRoleMappingRules({ rules: {} } as RoleMapping)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": "At least one rule is required.",
|
||||
"isInvalid": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
// more exhaustive testing is done in other unit tests
|
||||
it('requires rules to be valid', () => {
|
||||
expect(validateRoleMappingRules(({ rules: { something: [] } } as unknown) as RoleMapping))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": "Unknown rule type: something.",
|
||||
"isInvalid": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRoleMappingForSave', () => {
|
||||
it('fails if the role mapping is missing a name', () => {
|
||||
expect(
|
||||
validateRoleMappingForSave(({
|
||||
enabled: true,
|
||||
roles: ['superuser'],
|
||||
rules: { field: { username: '*' } },
|
||||
} as unknown) as RoleMapping)
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": "Name is required.",
|
||||
"isInvalid": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('fails if the role mapping is missing rules', () => {
|
||||
expect(
|
||||
validateRoleMappingForSave(({
|
||||
name: 'foo',
|
||||
enabled: true,
|
||||
roles: ['superuser'],
|
||||
rules: {},
|
||||
} as unknown) as RoleMapping)
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": "At least one rule is required.",
|
||||
"isInvalid": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('fails if the role mapping is missing both roles and templates', () => {
|
||||
expect(
|
||||
validateRoleMappingForSave(({
|
||||
name: 'foo',
|
||||
enabled: true,
|
||||
roles: [],
|
||||
role_templates: [],
|
||||
rules: { field: { username: '*' } },
|
||||
} as unknown) as RoleMapping)
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": "At least one role is required.",
|
||||
"isInvalid": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('validates a correct role mapping using role templates', () => {
|
||||
expect(
|
||||
validateRoleMappingForSave(({
|
||||
name: 'foo',
|
||||
enabled: true,
|
||||
roles: [],
|
||||
role_templates: [{ template: { id: 'foo' } }],
|
||||
rules: { field: { username: '*' } },
|
||||
} as unknown) as RoleMapping)
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"isInvalid": false,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('validates a correct role mapping using roles', () => {
|
||||
expect(
|
||||
validateRoleMappingForSave(({
|
||||
name: 'foo',
|
||||
enabled: true,
|
||||
roles: ['superuser'],
|
||||
rules: { field: { username: '*' } },
|
||||
} as unknown) as RoleMapping)
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"isInvalid": false,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { RoleMapping } from '../../../../../../common/model';
|
||||
import { generateRulesFromRaw } from '../../model';
|
||||
|
||||
interface ValidationResult {
|
||||
isInvalid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function validateRoleMappingName({ name }: RoleMapping): ValidationResult {
|
||||
if (!name) {
|
||||
return invalid(
|
||||
i18n.translate('xpack.security.role_mappings.validation.invalidName', {
|
||||
defaultMessage: 'Name is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
return valid();
|
||||
}
|
||||
|
||||
export function validateRoleMappingRoles({ roles }: RoleMapping): ValidationResult {
|
||||
if (roles && !roles.length) {
|
||||
return invalid(
|
||||
i18n.translate('xpack.security.role_mappings.validation.invalidRoles', {
|
||||
defaultMessage: 'At least one role is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
return valid();
|
||||
}
|
||||
|
||||
export function validateRoleMappingRoleTemplates({
|
||||
role_templates: roleTemplates,
|
||||
}: RoleMapping): ValidationResult {
|
||||
if (roleTemplates && !roleTemplates.length) {
|
||||
return invalid(
|
||||
i18n.translate('xpack.security.role_mappings.validation.invalidRoleTemplates', {
|
||||
defaultMessage: 'At least one role template is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
return valid();
|
||||
}
|
||||
|
||||
export function validateRoleMappingRules({ rules }: Pick<RoleMapping, 'rules'>): ValidationResult {
|
||||
try {
|
||||
const { rules: parsedRules } = generateRulesFromRaw(rules);
|
||||
if (!parsedRules) {
|
||||
return invalid(
|
||||
i18n.translate('xpack.security.role_mappings.validation.invalidRoleRule', {
|
||||
defaultMessage: 'At least one rule is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return invalid(e.message);
|
||||
}
|
||||
|
||||
return valid();
|
||||
}
|
||||
|
||||
export function validateRoleMappingForSave(roleMapping: RoleMapping): ValidationResult {
|
||||
const { isInvalid: isNameInvalid, error: nameError } = validateRoleMappingName(roleMapping);
|
||||
const { isInvalid: areRolesInvalid, error: rolesError } = validateRoleMappingRoles(roleMapping);
|
||||
const {
|
||||
isInvalid: areRoleTemplatesInvalid,
|
||||
error: roleTemplatesError,
|
||||
} = validateRoleMappingRoleTemplates(roleMapping);
|
||||
|
||||
const { isInvalid: areRulesInvalid, error: rulesError } = validateRoleMappingRules(roleMapping);
|
||||
|
||||
const canSave =
|
||||
!isNameInvalid && (!areRolesInvalid || !areRoleTemplatesInvalid) && !areRulesInvalid;
|
||||
|
||||
if (canSave) {
|
||||
return valid();
|
||||
}
|
||||
return invalid(nameError || rulesError || rolesError || roleTemplatesError);
|
||||
}
|
||||
|
||||
function valid() {
|
||||
return { isInvalid: false };
|
||||
}
|
||||
|
||||
function invalid(error?: string) {
|
||||
return { isInvalid: true, error };
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* 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 {
|
||||
isStoredRoleTemplate,
|
||||
isInlineRoleTemplate,
|
||||
isInvalidRoleTemplate,
|
||||
} from './role_template_type';
|
||||
import { RoleTemplate } from '../../../../../../common/model';
|
||||
|
||||
describe('#isStoredRoleTemplate', () => {
|
||||
it('returns true for stored templates, false otherwise', () => {
|
||||
expect(isStoredRoleTemplate({ template: { id: '' } })).toEqual(true);
|
||||
expect(isStoredRoleTemplate({ template: { source: '' } })).toEqual(false);
|
||||
expect(isStoredRoleTemplate({ template: 'asdf' })).toEqual(false);
|
||||
expect(isStoredRoleTemplate({} as RoleTemplate)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isInlineRoleTemplate', () => {
|
||||
it('returns true for inline templates, false otherwise', () => {
|
||||
expect(isInlineRoleTemplate({ template: { source: '' } })).toEqual(true);
|
||||
expect(isInlineRoleTemplate({ template: { id: '' } })).toEqual(false);
|
||||
expect(isInlineRoleTemplate({ template: 'asdf' })).toEqual(false);
|
||||
expect(isInlineRoleTemplate({} as RoleTemplate)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isInvalidRoleTemplate', () => {
|
||||
it('returns true for invalid templates, false otherwise', () => {
|
||||
expect(isInvalidRoleTemplate({ template: 'asdf' })).toEqual(true);
|
||||
expect(isInvalidRoleTemplate({} as RoleTemplate)).toEqual(true);
|
||||
expect(isInvalidRoleTemplate({ template: { source: '' } })).toEqual(false);
|
||||
expect(isInvalidRoleTemplate({ template: { id: '' } })).toEqual(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 {
|
||||
RoleTemplate,
|
||||
StoredRoleTemplate,
|
||||
InlineRoleTemplate,
|
||||
InvalidRoleTemplate,
|
||||
} from '../../../../../../common/model';
|
||||
|
||||
export function isStoredRoleTemplate(
|
||||
roleMappingTemplate: RoleTemplate
|
||||
): roleMappingTemplate is StoredRoleTemplate {
|
||||
return (
|
||||
roleMappingTemplate.template != null &&
|
||||
roleMappingTemplate.template.hasOwnProperty('id') &&
|
||||
typeof ((roleMappingTemplate as unknown) as StoredRoleTemplate).template.id === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function isInlineRoleTemplate(
|
||||
roleMappingTemplate: RoleTemplate
|
||||
): roleMappingTemplate is InlineRoleTemplate {
|
||||
return (
|
||||
roleMappingTemplate.template != null &&
|
||||
roleMappingTemplate.template.hasOwnProperty('source') &&
|
||||
typeof ((roleMappingTemplate as unknown) as InlineRoleTemplate).template.source === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function isInvalidRoleTemplate(
|
||||
roleMappingTemplate: RoleTemplate
|
||||
): roleMappingTemplate is InvalidRoleTemplate {
|
||||
return !isStoredRoleTemplate(roleMappingTemplate) && !isInlineRoleTemplate(roleMappingTemplate);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`generateRulesFromRaw "field" does not support a value of () => null 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found function ()."`;
|
||||
|
||||
exports[`generateRulesFromRaw "field" does not support a value of [object Object] 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found object ({})."`;
|
||||
|
||||
exports[`generateRulesFromRaw "field" does not support a value of [object Object],,,() => null 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found object ([{},null,[],null])."`;
|
||||
|
||||
exports[`generateRulesFromRaw "field" does not support a value of undefined 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found undefined ()."`;
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.';
|
||||
|
||||
describe('All rule', () => {
|
||||
it('can be constructed without sub rules', () => {
|
||||
const rule = new AllRule();
|
||||
expect(rule.getRules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can be constructed with sub rules', () => {
|
||||
const rule = new AllRule([new AnyRule()]);
|
||||
expect(rule.getRules()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('can accept rules of any type', () => {
|
||||
const subRules = [
|
||||
new AllRule(),
|
||||
new AnyRule(),
|
||||
new FieldRule('username', '*'),
|
||||
new ExceptAllRule(),
|
||||
new ExceptAnyRule(),
|
||||
];
|
||||
|
||||
const rule = new AllRule() as RuleGroup;
|
||||
expect(rule.canContainRules(subRules)).toEqual(true);
|
||||
subRules.forEach(sr => rule.addRule(sr));
|
||||
expect(rule.getRules()).toEqual([...subRules]);
|
||||
});
|
||||
|
||||
it('can replace an existing rule', () => {
|
||||
const rule = new AllRule([new AnyRule()]);
|
||||
const newRule = new FieldRule('username', '*');
|
||||
rule.replaceRule(0, newRule);
|
||||
expect(rule.getRules()).toEqual([newRule]);
|
||||
});
|
||||
|
||||
it('can remove an existing rule', () => {
|
||||
const rule = new AllRule([new AnyRule()]);
|
||||
rule.removeRule(0);
|
||||
expect(rule.getRules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can covert itself into a raw representation', () => {
|
||||
const rule = new AllRule([new AnyRule()]);
|
||||
expect(rule.toRaw()).toEqual({
|
||||
all: [{ any: [] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('can clone itself', () => {
|
||||
const subRules = [new AnyRule()];
|
||||
const rule = new AllRule(subRules);
|
||||
const clone = rule.clone();
|
||||
|
||||
expect(clone.toRaw()).toEqual(rule.toRaw());
|
||||
expect(clone.getRules()).toEqual(rule.getRules());
|
||||
expect(clone.getRules()).not.toBe(rule.getRules());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { RuleGroup } from './rule_group';
|
||||
import { Rule } from './rule';
|
||||
|
||||
/**
|
||||
* Represents a group of rules which must all evaluate to true.
|
||||
*/
|
||||
export class AllRule extends RuleGroup {
|
||||
constructor(private rules: Rule[] = []) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.getRules} */
|
||||
public getRules() {
|
||||
return [...this.rules];
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.getDisplayTitle} */
|
||||
public getDisplayTitle() {
|
||||
return i18n.translate('xpack.security.management.editRoleMapping.allRule.displayTitle', {
|
||||
defaultMessage: 'All are true',
|
||||
});
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.replaceRule} */
|
||||
public replaceRule(ruleIndex: number, rule: Rule) {
|
||||
this.rules.splice(ruleIndex, 1, rule);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.removeRule} */
|
||||
public removeRule(ruleIndex: number) {
|
||||
this.rules.splice(ruleIndex, 1);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.addRule} */
|
||||
public addRule(rule: Rule) {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.canContainRules} */
|
||||
public canContainRules() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.clone} */
|
||||
public clone() {
|
||||
return new AllRule(this.rules.map(r => r.clone()));
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.toRaw} */
|
||||
public toRaw() {
|
||||
return {
|
||||
all: [...this.rules.map(rule => rule.toRaw())],
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* 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 { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.';
|
||||
|
||||
describe('Any rule', () => {
|
||||
it('can be constructed without sub rules', () => {
|
||||
const rule = new AnyRule();
|
||||
expect(rule.getRules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can be constructed with sub rules', () => {
|
||||
const rule = new AnyRule([new AllRule()]);
|
||||
expect(rule.getRules()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('can accept non-except rules', () => {
|
||||
const subRules = [new AllRule(), new AnyRule(), new FieldRule('username', '*')];
|
||||
|
||||
const rule = new AnyRule() as RuleGroup;
|
||||
expect(rule.canContainRules(subRules)).toEqual(true);
|
||||
subRules.forEach(sr => rule.addRule(sr));
|
||||
expect(rule.getRules()).toEqual([...subRules]);
|
||||
});
|
||||
|
||||
it('cannot accept except rules', () => {
|
||||
const subRules = [new ExceptAllRule(), new ExceptAnyRule()];
|
||||
|
||||
const rule = new AnyRule() as RuleGroup;
|
||||
expect(rule.canContainRules(subRules)).toEqual(false);
|
||||
});
|
||||
|
||||
it('can replace an existing rule', () => {
|
||||
const rule = new AnyRule([new AllRule()]);
|
||||
const newRule = new FieldRule('username', '*');
|
||||
rule.replaceRule(0, newRule);
|
||||
expect(rule.getRules()).toEqual([newRule]);
|
||||
});
|
||||
|
||||
it('can remove an existing rule', () => {
|
||||
const rule = new AnyRule([new AllRule()]);
|
||||
rule.removeRule(0);
|
||||
expect(rule.getRules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can covert itself into a raw representation', () => {
|
||||
const rule = new AnyRule([new AllRule()]);
|
||||
expect(rule.toRaw()).toEqual({
|
||||
any: [{ all: [] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('can clone itself', () => {
|
||||
const subRules = [new AllRule()];
|
||||
const rule = new AnyRule(subRules);
|
||||
const clone = rule.clone();
|
||||
|
||||
expect(clone.toRaw()).toEqual(rule.toRaw());
|
||||
expect(clone.getRules()).toEqual(rule.getRules());
|
||||
expect(clone.getRules()).not.toBe(rule.getRules());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { RuleGroup } from './rule_group';
|
||||
import { Rule } from './rule';
|
||||
import { ExceptAllRule } from './except_all_rule';
|
||||
import { ExceptAnyRule } from './except_any_rule';
|
||||
|
||||
/**
|
||||
* Represents a group of rules in which at least one must evaluate to true.
|
||||
*/
|
||||
export class AnyRule extends RuleGroup {
|
||||
constructor(private rules: Rule[] = []) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.getRules} */
|
||||
public getRules() {
|
||||
return [...this.rules];
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.getDisplayTitle} */
|
||||
public getDisplayTitle() {
|
||||
return i18n.translate('xpack.security.management.editRoleMapping.anyRule.displayTitle', {
|
||||
defaultMessage: 'Any are true',
|
||||
});
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.replaceRule} */
|
||||
public replaceRule(ruleIndex: number, rule: Rule) {
|
||||
this.rules.splice(ruleIndex, 1, rule);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.removeRule} */
|
||||
public removeRule(ruleIndex: number) {
|
||||
this.rules.splice(ruleIndex, 1);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.addRule} */
|
||||
public addRule(rule: Rule) {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.canContainRules} */
|
||||
public canContainRules(rules: Rule[]) {
|
||||
const forbiddenRules = [ExceptAllRule, ExceptAnyRule];
|
||||
return rules.every(
|
||||
candidate => !forbiddenRules.some(forbidden => candidate instanceof forbidden)
|
||||
);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.clone} */
|
||||
public clone() {
|
||||
return new AnyRule(this.rules.map(r => r.clone()));
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.toRaw} */
|
||||
public toRaw() {
|
||||
return {
|
||||
any: [...this.rules.map(rule => rule.toRaw())],
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.';
|
||||
|
||||
describe('Except All rule', () => {
|
||||
it('can be constructed without sub rules', () => {
|
||||
const rule = new ExceptAllRule();
|
||||
expect(rule.getRules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can be constructed with sub rules', () => {
|
||||
const rule = new ExceptAllRule([new AnyRule()]);
|
||||
expect(rule.getRules()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('can accept rules of any type', () => {
|
||||
const subRules = [
|
||||
new AllRule(),
|
||||
new AnyRule(),
|
||||
new FieldRule('username', '*'),
|
||||
new ExceptAllRule(),
|
||||
new ExceptAnyRule(),
|
||||
];
|
||||
|
||||
const rule = new ExceptAllRule() as RuleGroup;
|
||||
expect(rule.canContainRules(subRules)).toEqual(true);
|
||||
subRules.forEach(sr => rule.addRule(sr));
|
||||
expect(rule.getRules()).toEqual([...subRules]);
|
||||
});
|
||||
|
||||
it('can replace an existing rule', () => {
|
||||
const rule = new ExceptAllRule([new AnyRule()]);
|
||||
const newRule = new FieldRule('username', '*');
|
||||
rule.replaceRule(0, newRule);
|
||||
expect(rule.getRules()).toEqual([newRule]);
|
||||
});
|
||||
|
||||
it('can remove an existing rule', () => {
|
||||
const rule = new ExceptAllRule([new AnyRule()]);
|
||||
rule.removeRule(0);
|
||||
expect(rule.getRules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can covert itself into a raw representation', () => {
|
||||
const rule = new ExceptAllRule([new AnyRule()]);
|
||||
expect(rule.toRaw()).toEqual({
|
||||
except: { all: [{ any: [] }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('can clone itself', () => {
|
||||
const subRules = [new AllRule()];
|
||||
const rule = new ExceptAllRule(subRules);
|
||||
const clone = rule.clone();
|
||||
|
||||
expect(clone.toRaw()).toEqual(rule.toRaw());
|
||||
expect(clone.getRules()).toEqual(rule.getRules());
|
||||
expect(clone.getRules()).not.toBe(rule.getRules());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { RuleGroup } from './rule_group';
|
||||
import { Rule } from './rule';
|
||||
|
||||
/**
|
||||
* Represents a group of rules in which at least one must evaluate to false.
|
||||
*/
|
||||
export class ExceptAllRule extends RuleGroup {
|
||||
constructor(private rules: Rule[] = []) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.getRules} */
|
||||
public getRules() {
|
||||
return [...this.rules];
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.getDisplayTitle} */
|
||||
public getDisplayTitle() {
|
||||
return i18n.translate('xpack.security.management.editRoleMapping.exceptAllRule.displayTitle', {
|
||||
defaultMessage: 'Any are false',
|
||||
});
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.replaceRule} */
|
||||
public replaceRule(ruleIndex: number, rule: Rule) {
|
||||
this.rules.splice(ruleIndex, 1, rule);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.removeRule} */
|
||||
public removeRule(ruleIndex: number) {
|
||||
this.rules.splice(ruleIndex, 1);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.addRule} */
|
||||
public addRule(rule: Rule) {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.canContainRules} */
|
||||
public canContainRules() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.clone} */
|
||||
public clone() {
|
||||
return new ExceptAllRule(this.rules.map(r => r.clone()));
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.toRaw} */
|
||||
public toRaw() {
|
||||
const rawRule = {
|
||||
all: [...this.rules.map(rule => rule.toRaw())],
|
||||
};
|
||||
|
||||
return {
|
||||
except: rawRule,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* 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 { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.';
|
||||
|
||||
describe('Except Any rule', () => {
|
||||
it('can be constructed without sub rules', () => {
|
||||
const rule = new ExceptAnyRule();
|
||||
expect(rule.getRules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can be constructed with sub rules', () => {
|
||||
const rule = new ExceptAnyRule([new AllRule()]);
|
||||
expect(rule.getRules()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('can accept non-except rules', () => {
|
||||
const subRules = [new AllRule(), new AnyRule(), new FieldRule('username', '*')];
|
||||
|
||||
const rule = new ExceptAnyRule() as RuleGroup;
|
||||
expect(rule.canContainRules(subRules)).toEqual(true);
|
||||
subRules.forEach(sr => rule.addRule(sr));
|
||||
expect(rule.getRules()).toEqual([...subRules]);
|
||||
});
|
||||
|
||||
it('cannot accept except rules', () => {
|
||||
const subRules = [new ExceptAllRule(), new ExceptAnyRule()];
|
||||
|
||||
const rule = new ExceptAnyRule() as RuleGroup;
|
||||
expect(rule.canContainRules(subRules)).toEqual(false);
|
||||
});
|
||||
|
||||
it('can replace an existing rule', () => {
|
||||
const rule = new ExceptAnyRule([new AllRule()]);
|
||||
const newRule = new FieldRule('username', '*');
|
||||
rule.replaceRule(0, newRule);
|
||||
expect(rule.getRules()).toEqual([newRule]);
|
||||
});
|
||||
|
||||
it('can remove an existing rule', () => {
|
||||
const rule = new ExceptAnyRule([new AllRule()]);
|
||||
rule.removeRule(0);
|
||||
expect(rule.getRules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can covert itself into a raw representation', () => {
|
||||
const rule = new ExceptAnyRule([new AllRule()]);
|
||||
expect(rule.toRaw()).toEqual({
|
||||
except: { any: [{ all: [] }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('can clone itself', () => {
|
||||
const subRules = [new AllRule()];
|
||||
const rule = new ExceptAnyRule(subRules);
|
||||
const clone = rule.clone();
|
||||
|
||||
expect(clone.toRaw()).toEqual(rule.toRaw());
|
||||
expect(clone.getRules()).toEqual(rule.getRules());
|
||||
expect(clone.getRules()).not.toBe(rule.getRules());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { RuleGroup } from './rule_group';
|
||||
import { Rule } from './rule';
|
||||
import { ExceptAllRule } from './except_all_rule';
|
||||
|
||||
/**
|
||||
* Represents a group of rules in which none can evaluate to true (all must evaluate to false).
|
||||
*/
|
||||
export class ExceptAnyRule extends RuleGroup {
|
||||
constructor(private rules: Rule[] = []) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.getRules} */
|
||||
public getRules() {
|
||||
return [...this.rules];
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.getDisplayTitle} */
|
||||
public getDisplayTitle() {
|
||||
return i18n.translate('xpack.security.management.editRoleMapping.exceptAnyRule.displayTitle', {
|
||||
defaultMessage: 'All are false',
|
||||
});
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.replaceRule} */
|
||||
public replaceRule(ruleIndex: number, rule: Rule) {
|
||||
this.rules.splice(ruleIndex, 1, rule);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.removeRule} */
|
||||
public removeRule(ruleIndex: number) {
|
||||
this.rules.splice(ruleIndex, 1);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.addRule} */
|
||||
public addRule(rule: Rule) {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.canContainRules} */
|
||||
public canContainRules(rules: Rule[]) {
|
||||
const forbiddenRules = [ExceptAllRule, ExceptAnyRule];
|
||||
return rules.every(
|
||||
candidate => !forbiddenRules.some(forbidden => candidate instanceof forbidden)
|
||||
);
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.clone} */
|
||||
public clone() {
|
||||
return new ExceptAnyRule(this.rules.map(r => r.clone()));
|
||||
}
|
||||
|
||||
/** {@see RuleGroup.toRaw} */
|
||||
public toRaw() {
|
||||
const rawRule = {
|
||||
any: [...this.rules.map(rule => rule.toRaw())],
|
||||
};
|
||||
|
||||
return {
|
||||
except: rawRule,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 { FieldRule } from '.';
|
||||
|
||||
describe('FieldRule', () => {
|
||||
['*', 1, null, true, false].forEach(value => {
|
||||
it(`can convert itself to raw form with a single value of ${value}`, () => {
|
||||
const rule = new FieldRule('username', value);
|
||||
expect(rule.toRaw()).toEqual({
|
||||
field: {
|
||||
username: value,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('can convert itself to raw form with an array of values', () => {
|
||||
const values = ['*', 1, null, true, false];
|
||||
const rule = new FieldRule('username', values);
|
||||
const raw = rule.toRaw();
|
||||
expect(raw).toEqual({
|
||||
field: {
|
||||
username: ['*', 1, null, true, false],
|
||||
},
|
||||
});
|
||||
|
||||
// shoud not be the same array instance
|
||||
expect(raw.field.username).not.toBe(values);
|
||||
});
|
||||
|
||||
it('can clone itself', () => {
|
||||
const values = ['*', 1, null];
|
||||
const rule = new FieldRule('username', values);
|
||||
|
||||
const clone = rule.clone();
|
||||
expect(clone.field).toEqual(rule.field);
|
||||
expect(clone.value).toEqual(rule.value);
|
||||
expect(clone.value).not.toBe(rule.value);
|
||||
expect(clone.toRaw()).toEqual(rule.toRaw());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { Rule } from './rule';
|
||||
|
||||
/** The allowed types for field rule values */
|
||||
export type FieldRuleValue =
|
||||
| string
|
||||
| number
|
||||
| null
|
||||
| boolean
|
||||
| Array<string | number | null | boolean>;
|
||||
|
||||
/**
|
||||
* Represents a single field rule.
|
||||
* Ex: "username = 'foo'"
|
||||
*/
|
||||
export class FieldRule extends Rule {
|
||||
constructor(public readonly field: string, public readonly value: FieldRuleValue) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** {@see Rule.getDisplayTitle} */
|
||||
public getDisplayTitle() {
|
||||
return i18n.translate('xpack.security.management.editRoleMapping.fieldRule.displayTitle', {
|
||||
defaultMessage: 'The following is true',
|
||||
});
|
||||
}
|
||||
|
||||
/** {@see Rule.clone} */
|
||||
public clone() {
|
||||
return new FieldRule(this.field, Array.isArray(this.value) ? [...this.value] : this.value);
|
||||
}
|
||||
|
||||
/** {@see Rule.toRaw} */
|
||||
public toRaw() {
|
||||
return {
|
||||
field: {
|
||||
[this.field]: Array.isArray(this.value) ? [...this.value] : this.value,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 { AllRule } from './all_rule';
|
||||
export { AnyRule } from './any_rule';
|
||||
export { Rule } from './rule';
|
||||
export { RuleGroup } from './rule_group';
|
||||
export { ExceptAllRule } from './except_all_rule';
|
||||
export { ExceptAnyRule } from './except_any_rule';
|
||||
export { FieldRule, FieldRuleValue } from './field_rule';
|
||||
export { generateRulesFromRaw } from './rule_builder';
|
||||
export { RuleBuilderError } from './rule_builder_error';
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a Role Mapping rule.
|
||||
*/
|
||||
export abstract class Rule {
|
||||
/**
|
||||
* Converts this rule into a raw object for use in the persisted Role Mapping.
|
||||
*/
|
||||
abstract toRaw(): Record<string, any>;
|
||||
|
||||
/**
|
||||
* The display title for this rule.
|
||||
*/
|
||||
abstract getDisplayTitle(): string;
|
||||
|
||||
/**
|
||||
* Returns a new instance of this rule.
|
||||
*/
|
||||
abstract clone(): Rule;
|
||||
}
|
|
@ -0,0 +1,343 @@
|
|||
/*
|
||||
* 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 { generateRulesFromRaw, FieldRule } from '.';
|
||||
import { RoleMapping } from '../../../../../common/model';
|
||||
import { RuleBuilderError } from './rule_builder_error';
|
||||
|
||||
describe('generateRulesFromRaw', () => {
|
||||
it('returns null for an empty rule set', () => {
|
||||
expect(generateRulesFromRaw({})).toEqual({
|
||||
rules: null,
|
||||
maxDepth: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a correctly parsed rule set', () => {
|
||||
const rawRules: RoleMapping['rules'] = {
|
||||
all: [
|
||||
{
|
||||
except: {
|
||||
all: [
|
||||
{
|
||||
field: { username: '*' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
any: [
|
||||
{
|
||||
field: { dn: '*' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { rules, maxDepth } = generateRulesFromRaw(rawRules);
|
||||
|
||||
expect(rules).toMatchInlineSnapshot(`
|
||||
AllRule {
|
||||
"rules": Array [
|
||||
ExceptAllRule {
|
||||
"rules": Array [
|
||||
FieldRule {
|
||||
"field": "username",
|
||||
"value": "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
AnyRule {
|
||||
"rules": Array [
|
||||
FieldRule {
|
||||
"field": "dn",
|
||||
"value": "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
expect(maxDepth).toEqual(3);
|
||||
});
|
||||
|
||||
it('does not support multiple rules at the root level', () => {
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
all: [
|
||||
{
|
||||
field: { username: '*' },
|
||||
},
|
||||
],
|
||||
any: [
|
||||
{
|
||||
field: { username: '*' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}).toThrowError('Expected a single rule definition, but found 2.');
|
||||
});
|
||||
|
||||
it('provides a rule trace describing the location of the error', () => {
|
||||
try {
|
||||
generateRulesFromRaw({
|
||||
all: [
|
||||
{
|
||||
field: { username: '*' },
|
||||
},
|
||||
{
|
||||
any: [
|
||||
{
|
||||
field: { username: '*' },
|
||||
},
|
||||
{
|
||||
except: { field: { username: '*' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
throw new Error(`Expected generateRulesFromRaw to throw error.`);
|
||||
} catch (e) {
|
||||
if (e instanceof RuleBuilderError) {
|
||||
expect(e.message).toEqual(`"except" rule can only exist within an "all" rule.`);
|
||||
expect(e.ruleTrace).toEqual(['all', '[1]', 'any', '[1]', 'except']);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('calculates the max depth of the rule tree', () => {
|
||||
const rules = {
|
||||
all: [
|
||||
// depth = 1
|
||||
{
|
||||
// depth = 2
|
||||
all: [
|
||||
// depth = 3
|
||||
{
|
||||
any: [
|
||||
// depth == 4
|
||||
{ field: { username: 'foo' } },
|
||||
],
|
||||
},
|
||||
{ except: { field: { username: 'foo' } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
// depth = 2
|
||||
any: [
|
||||
{
|
||||
// depth = 3
|
||||
all: [
|
||||
{
|
||||
// depth = 4
|
||||
any: [
|
||||
{
|
||||
// depth = 5
|
||||
all: [
|
||||
{
|
||||
// depth = 6
|
||||
all: [
|
||||
// depth = 7
|
||||
{
|
||||
except: {
|
||||
field: { username: 'foo' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(generateRulesFromRaw(rules).maxDepth).toEqual(7);
|
||||
});
|
||||
|
||||
describe('"any"', () => {
|
||||
it('expects an array value', () => {
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
any: {
|
||||
field: { username: '*' },
|
||||
} as any,
|
||||
});
|
||||
}).toThrowError('Expected an array of rules, but found object.');
|
||||
});
|
||||
|
||||
it('expects each entry to be an object with a single property', () => {
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
any: [
|
||||
{
|
||||
any: [{ field: { foo: 'bar' } }],
|
||||
all: [{ field: { foo: 'bar' } }],
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
}).toThrowError('Expected a single rule definition, but found 2.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('"all"', () => {
|
||||
it('expects an array value', () => {
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
all: {
|
||||
field: { username: '*' },
|
||||
} as any,
|
||||
});
|
||||
}).toThrowError('Expected an array of rules, but found object.');
|
||||
});
|
||||
|
||||
it('expects each entry to be an object with a single property', () => {
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
all: [
|
||||
{
|
||||
field: { username: '*' },
|
||||
any: [{ field: { foo: 'bar' } }],
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
}).toThrowError('Expected a single rule definition, but found 2.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('"field"', () => {
|
||||
it(`expects an object value`, () => {
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
field: [
|
||||
{
|
||||
username: '*',
|
||||
},
|
||||
],
|
||||
});
|
||||
}).toThrowError('Expected an object, but found array.');
|
||||
});
|
||||
|
||||
it(`expects an single property in its object value`, () => {
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
field: {
|
||||
username: '*',
|
||||
dn: '*',
|
||||
},
|
||||
});
|
||||
}).toThrowError('Expected a single field, but found 2.');
|
||||
});
|
||||
|
||||
it('accepts an array of possible values', () => {
|
||||
const { rules } = generateRulesFromRaw({
|
||||
field: {
|
||||
username: [0, '*', null, 'foo', true, false],
|
||||
},
|
||||
});
|
||||
|
||||
expect(rules).toBeInstanceOf(FieldRule);
|
||||
expect((rules as FieldRule).field).toEqual('username');
|
||||
expect((rules as FieldRule).value).toEqual([0, '*', null, 'foo', true, false]);
|
||||
});
|
||||
|
||||
[{}, () => null, undefined, [{}, undefined, [], () => null]].forEach(invalidValue => {
|
||||
it(`does not support a value of ${invalidValue}`, () => {
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
field: {
|
||||
username: invalidValue,
|
||||
},
|
||||
});
|
||||
}).toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('"except"', () => {
|
||||
it(`expects an object value`, () => {
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
all: [
|
||||
{
|
||||
except: [
|
||||
{
|
||||
field: { username: '*' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
}).toThrowError('Expected an object, but found array.');
|
||||
});
|
||||
|
||||
it(`can only be nested inside an "all" clause`, () => {
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
any: [
|
||||
{
|
||||
except: {
|
||||
field: {
|
||||
username: '*',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}).toThrowError(`"except" rule can only exist within an "all" rule.`);
|
||||
|
||||
expect(() => {
|
||||
generateRulesFromRaw({
|
||||
except: {
|
||||
field: {
|
||||
username: '*',
|
||||
},
|
||||
},
|
||||
});
|
||||
}).toThrowError(`"except" rule can only exist within an "all" rule.`);
|
||||
});
|
||||
|
||||
it('converts an "except field" rule into an equivilent "except all" rule', () => {
|
||||
expect(
|
||||
generateRulesFromRaw({
|
||||
all: [
|
||||
{
|
||||
except: {
|
||||
field: {
|
||||
username: '*',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"maxDepth": 2,
|
||||
"rules": AllRule {
|
||||
"rules": Array [
|
||||
ExceptAllRule {
|
||||
"rules": Array [
|
||||
FieldRule {
|
||||
"field": "username",
|
||||
"value": "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { RoleMapping } from '../../../../../common/model';
|
||||
import { FieldRule, FieldRuleValue } from './field_rule';
|
||||
import { AllRule } from './all_rule';
|
||||
import { AnyRule } from './any_rule';
|
||||
import { Rule } from './rule';
|
||||
import { ExceptAllRule } from './except_all_rule';
|
||||
import { ExceptAnyRule } from './except_any_rule';
|
||||
import { RuleBuilderError } from '.';
|
||||
|
||||
interface RuleBuilderResult {
|
||||
/** The maximum rule depth within the parsed rule set. */
|
||||
maxDepth: number;
|
||||
|
||||
/** The parsed rule set. */
|
||||
rules: Rule | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a set of raw rules, this constructs a class based tree for consumption by the Role Management UI.
|
||||
* This also performs validation on the raw rule set, as it is possible to enter raw JSON in the JSONRuleEditor,
|
||||
* so we have no guarantees that the rule set is valid ahead of time.
|
||||
*
|
||||
* @param rawRules the raw rules to translate.
|
||||
*/
|
||||
export function generateRulesFromRaw(rawRules: RoleMapping['rules'] = {}): RuleBuilderResult {
|
||||
return parseRawRules(rawRules, null, [], 0);
|
||||
}
|
||||
|
||||
function parseRawRules(
|
||||
rawRules: RoleMapping['rules'],
|
||||
parentRuleType: string | null,
|
||||
ruleTrace: string[],
|
||||
depth: number
|
||||
): RuleBuilderResult {
|
||||
const entries = Object.entries(rawRules);
|
||||
if (!entries.length) {
|
||||
return {
|
||||
rules: null,
|
||||
maxDepth: 0,
|
||||
};
|
||||
}
|
||||
if (entries.length > 1) {
|
||||
throw new RuleBuilderError(
|
||||
i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.expectSingleRule', {
|
||||
defaultMessage: `Expected a single rule definition, but found {numberOfRules}.`,
|
||||
values: { numberOfRules: entries.length },
|
||||
}),
|
||||
ruleTrace
|
||||
);
|
||||
}
|
||||
|
||||
const rule = entries[0];
|
||||
const [ruleType, ruleDefinition] = rule;
|
||||
return createRuleForType(ruleType, ruleDefinition, parentRuleType, ruleTrace, depth + 1);
|
||||
}
|
||||
|
||||
function createRuleForType(
|
||||
ruleType: string,
|
||||
ruleDefinition: any,
|
||||
parentRuleType: string | null,
|
||||
ruleTrace: string[] = [],
|
||||
depth: number
|
||||
): RuleBuilderResult {
|
||||
const isRuleNegated = parentRuleType === 'except';
|
||||
|
||||
const currentRuleTrace = [...ruleTrace, ruleType];
|
||||
|
||||
switch (ruleType) {
|
||||
case 'field': {
|
||||
assertIsObject(ruleDefinition, currentRuleTrace);
|
||||
|
||||
const entries = Object.entries(ruleDefinition);
|
||||
if (entries.length !== 1) {
|
||||
throw new RuleBuilderError(
|
||||
i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.ruleBuilder.expectedSingleFieldRule',
|
||||
{
|
||||
defaultMessage: `Expected a single field, but found {count}.`,
|
||||
values: { count: entries.length },
|
||||
}
|
||||
),
|
||||
currentRuleTrace
|
||||
);
|
||||
}
|
||||
|
||||
const [field, value] = entries[0] as [string, FieldRuleValue];
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
values.forEach(fieldValue => {
|
||||
const valueType = typeof fieldValue;
|
||||
if (fieldValue !== null && !['string', 'number', 'boolean'].includes(valueType)) {
|
||||
throw new RuleBuilderError(
|
||||
i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.ruleBuilder.invalidFieldValueType',
|
||||
{
|
||||
defaultMessage: `Invalid value type for field. Expected one of null, string, number, or boolean, but found {valueType} ({value}).`,
|
||||
values: { valueType, value: JSON.stringify(value) },
|
||||
}
|
||||
),
|
||||
currentRuleTrace
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const fieldRule = new FieldRule(field, value);
|
||||
return {
|
||||
rules: isRuleNegated ? new ExceptAllRule([fieldRule]) : fieldRule,
|
||||
maxDepth: depth,
|
||||
};
|
||||
}
|
||||
case 'any': // intentional fall-through to 'all', as validation logic is identical
|
||||
case 'all': {
|
||||
if (ruleDefinition != null && !Array.isArray(ruleDefinition)) {
|
||||
throw new RuleBuilderError(
|
||||
i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.ruleBuilder.expectedArrayForGroupRule',
|
||||
{
|
||||
defaultMessage: `Expected an array of rules, but found {type}.`,
|
||||
values: { type: typeof ruleDefinition },
|
||||
}
|
||||
),
|
||||
currentRuleTrace
|
||||
);
|
||||
}
|
||||
|
||||
const subRulesResults = ((ruleDefinition as any[]) || []).map((definition: any, index) =>
|
||||
parseRawRules(definition, ruleType, [...currentRuleTrace, `[${index}]`], depth)
|
||||
) as RuleBuilderResult[];
|
||||
|
||||
const { subRules, maxDepth } = subRulesResults.reduce(
|
||||
(acc, result) => {
|
||||
return {
|
||||
subRules: [...acc.subRules, result.rules!],
|
||||
maxDepth: Math.max(acc.maxDepth, result.maxDepth),
|
||||
};
|
||||
},
|
||||
{ subRules: [] as Rule[], maxDepth: 0 }
|
||||
);
|
||||
|
||||
if (ruleType === 'all') {
|
||||
return {
|
||||
rules: isRuleNegated ? new ExceptAllRule(subRules) : new AllRule(subRules),
|
||||
maxDepth,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
rules: isRuleNegated ? new ExceptAnyRule(subRules) : new AnyRule(subRules),
|
||||
maxDepth,
|
||||
};
|
||||
}
|
||||
}
|
||||
case 'except': {
|
||||
assertIsObject(ruleDefinition, currentRuleTrace);
|
||||
|
||||
if (parentRuleType !== 'all') {
|
||||
throw new RuleBuilderError(
|
||||
i18n.translate(
|
||||
'xpack.security.management.editRoleMapping.ruleBuilder.exceptOnlyInAllRule',
|
||||
{
|
||||
defaultMessage: `"except" rule can only exist within an "all" rule.`,
|
||||
}
|
||||
),
|
||||
currentRuleTrace
|
||||
);
|
||||
}
|
||||
// subtracting 1 from depth because we don't currently count the "except" level itself as part of the depth calculation
|
||||
// for the purpose of determining if the rule set is "too complex" for the visual rule editor.
|
||||
// The "except" rule MUST be nested within an "all" rule type (see validation above), so the depth itself will always be a non-negative number.
|
||||
return parseRawRules(ruleDefinition || {}, ruleType, currentRuleTrace, depth - 1);
|
||||
}
|
||||
default:
|
||||
throw new RuleBuilderError(
|
||||
i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.unknownRuleType', {
|
||||
defaultMessage: `Unknown rule type: {ruleType}.`,
|
||||
values: { ruleType },
|
||||
}),
|
||||
currentRuleTrace
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIsObject(ruleDefinition: any, ruleTrace: string[]) {
|
||||
let fieldType: string = typeof ruleDefinition;
|
||||
if (Array.isArray(ruleDefinition)) {
|
||||
fieldType = 'array';
|
||||
}
|
||||
|
||||
if (ruleDefinition && fieldType !== 'object') {
|
||||
throw new RuleBuilderError(
|
||||
i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.expectedObjectError', {
|
||||
defaultMessage: `Expected an object, but found {type}.`,
|
||||
values: { type: fieldType },
|
||||
}),
|
||||
ruleTrace
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Describes an error during rule building.
|
||||
* In addition to a user-"friendly" message, this also includes a rule trace,
|
||||
* which is the "JSON path" where the error occurred.
|
||||
*/
|
||||
export class RuleBuilderError extends Error {
|
||||
constructor(message: string, public readonly ruleTrace: string[]) {
|
||||
super(message);
|
||||
|
||||
// Set the prototype explicitly, see:
|
||||
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
|
||||
Object.setPrototypeOf(this, RuleBuilderError.prototype);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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 { Rule } from './rule';
|
||||
|
||||
/**
|
||||
* Represents a catagory of Role Mapping rules which are capable of containing other rules.
|
||||
*/
|
||||
export abstract class RuleGroup extends Rule {
|
||||
/**
|
||||
* Returns all immediate sub-rules within this group (non-recursive).
|
||||
*/
|
||||
abstract getRules(): Rule[];
|
||||
|
||||
/**
|
||||
* Replaces the rule at the indicated location.
|
||||
* @param ruleIndex the location of the rule to replace.
|
||||
* @param rule the new rule.
|
||||
*/
|
||||
abstract replaceRule(ruleIndex: number, rule: Rule): void;
|
||||
|
||||
/**
|
||||
* Removes the rule at the indicated location.
|
||||
* @param ruleIndex the location of the rule to remove.
|
||||
*/
|
||||
abstract removeRule(ruleIndex: number): void;
|
||||
|
||||
/**
|
||||
* Adds a rule to this group.
|
||||
* @param rule the rule to add.
|
||||
*/
|
||||
abstract addRule(rule: Rule): void;
|
||||
|
||||
/**
|
||||
* Determines if the provided rules are allowed to be contained within this group.
|
||||
* @param rules the rules to test.
|
||||
*/
|
||||
abstract canContainRules(rules: Rule[]): boolean;
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { getCreateRoleMappingHref } from '../../../../management_urls';
|
||||
|
||||
export const CreateRoleMappingButton = () => {
|
||||
return (
|
||||
<EuiButton data-test-subj="createRoleMappingButton" href={getCreateRoleMappingHref()} fill>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.createRoleMappingButton"
|
||||
defaultMessage="Create role mapping"
|
||||
/>
|
||||
</EuiButton>
|
||||
);
|
||||
};
|
|
@ -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 { CreateRoleMappingButton } from './create_role_mapping_button';
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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 React, { Fragment } from 'react';
|
||||
import { EuiEmptyPrompt } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { CreateRoleMappingButton } from '../create_role_mapping_button';
|
||||
|
||||
export const EmptyPrompt: React.FunctionComponent<{}> = () => (
|
||||
<EuiEmptyPrompt
|
||||
iconType="managementApp"
|
||||
title={
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.emptyPromptTitle"
|
||||
defaultMessage="Create your first role mapping"
|
||||
/>
|
||||
</h1>
|
||||
}
|
||||
body={
|
||||
<Fragment>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.emptyPromptDescription"
|
||||
defaultMessage="Role mappings control which roles are assigned to your users."
|
||||
/>
|
||||
</p>
|
||||
</Fragment>
|
||||
}
|
||||
actions={<CreateRoleMappingButton />}
|
||||
data-test-subj="roleMappingsEmptyPrompt"
|
||||
/>
|
||||
);
|
|
@ -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 { EmptyPrompt } from './empty_prompt';
|
|
@ -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 { RoleMappingsGridPage } from './role_mappings_grid_page';
|
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
|
||||
import { RoleMappingsGridPage } from '.';
|
||||
import { SectionLoading, PermissionDenied, NoCompatibleRealms } from '../../components';
|
||||
import { EmptyPrompt } from './empty_prompt';
|
||||
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
describe('RoleMappingsGridPage', () => {
|
||||
it('renders an empty prompt when no role mappings exist', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
getRoleMappings: jest.fn().mockResolvedValue([]),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: true,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(<RoleMappingsGridPage roleMappingsAPI={roleMappingsAPI} />);
|
||||
expect(wrapper.find(SectionLoading)).toHaveLength(1);
|
||||
expect(wrapper.find(EmptyPrompt)).toHaveLength(0);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(SectionLoading)).toHaveLength(0);
|
||||
expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0);
|
||||
expect(wrapper.find(EmptyPrompt)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a permission denied message when unauthorized to manage role mappings', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: false,
|
||||
hasCompatibleRealms: true,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(<RoleMappingsGridPage roleMappingsAPI={roleMappingsAPI} />);
|
||||
expect(wrapper.find(SectionLoading)).toHaveLength(1);
|
||||
expect(wrapper.find(PermissionDenied)).toHaveLength(0);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(SectionLoading)).toHaveLength(0);
|
||||
expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0);
|
||||
expect(wrapper.find(PermissionDenied)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a warning when there are no compatible realms enabled', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
getRoleMappings: jest.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'some realm',
|
||||
enabled: true,
|
||||
roles: [],
|
||||
rules: { field: { username: '*' } },
|
||||
},
|
||||
]),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: false,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(<RoleMappingsGridPage roleMappingsAPI={roleMappingsAPI} />);
|
||||
expect(wrapper.find(SectionLoading)).toHaveLength(1);
|
||||
expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0);
|
||||
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(SectionLoading)).toHaveLength(0);
|
||||
expect(wrapper.find(NoCompatibleRealms)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders links to mapped roles', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
getRoleMappings: jest.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'some realm',
|
||||
enabled: true,
|
||||
roles: ['superuser'],
|
||||
rules: { field: { username: '*' } },
|
||||
},
|
||||
]),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: true,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(<RoleMappingsGridPage roleMappingsAPI={roleMappingsAPI} />);
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
const links = findTestSubject(wrapper, 'roleMappingRoles').find(EuiLink);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links.at(0).props()).toMatchObject({
|
||||
href: '#/management/security/roles/edit/superuser',
|
||||
});
|
||||
});
|
||||
|
||||
it('describes the number of mapped role templates', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
getRoleMappings: jest.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'some realm',
|
||||
enabled: true,
|
||||
role_templates: [{}, {}],
|
||||
rules: { field: { username: '*' } },
|
||||
},
|
||||
]),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: true,
|
||||
}),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(<RoleMappingsGridPage roleMappingsAPI={roleMappingsAPI} />);
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
const templates = findTestSubject(wrapper, 'roleMappingRoles');
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates.text()).toEqual(`2 role templates defined`);
|
||||
});
|
||||
|
||||
it('allows role mappings to be deleted, refreshing the grid after', async () => {
|
||||
const roleMappingsAPI = ({
|
||||
getRoleMappings: jest.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'some-realm',
|
||||
enabled: true,
|
||||
roles: ['superuser'],
|
||||
rules: { field: { username: '*' } },
|
||||
},
|
||||
]),
|
||||
checkRoleMappingFeatures: jest.fn().mockResolvedValue({
|
||||
canManageRoleMappings: true,
|
||||
hasCompatibleRealms: true,
|
||||
}),
|
||||
deleteRoleMappings: jest.fn().mockReturnValue(
|
||||
Promise.resolve([
|
||||
{
|
||||
name: 'some-realm',
|
||||
success: true,
|
||||
},
|
||||
])
|
||||
),
|
||||
} as unknown) as RoleMappingsAPI;
|
||||
|
||||
const wrapper = mountWithIntl(<RoleMappingsGridPage roleMappingsAPI={roleMappingsAPI} />);
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(roleMappingsAPI.getRoleMappings).toHaveBeenCalledTimes(1);
|
||||
expect(roleMappingsAPI.deleteRoleMappings).not.toHaveBeenCalled();
|
||||
|
||||
findTestSubject(wrapper, `deleteRoleMappingButton-some-realm`).simulate('click');
|
||||
expect(findTestSubject(wrapper, 'deleteRoleMappingConfirmationModal')).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['some-realm']);
|
||||
// Expect an additional API call to refresh the grid
|
||||
expect(roleMappingsAPI.getRoleMappings).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,474 @@
|
|||
/*
|
||||
* 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 React, { Component, Fragment } from 'react';
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiButton,
|
||||
EuiButtonIcon,
|
||||
EuiCallOut,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiInMemoryTable,
|
||||
EuiLink,
|
||||
EuiPageContent,
|
||||
EuiPageContentBody,
|
||||
EuiPageContentHeader,
|
||||
EuiPageContentHeaderSection,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { RoleMapping } from '../../../../../../common/model';
|
||||
import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
|
||||
import { EmptyPrompt } from './empty_prompt';
|
||||
import {
|
||||
NoCompatibleRealms,
|
||||
DeleteProvider,
|
||||
PermissionDenied,
|
||||
SectionLoading,
|
||||
} from '../../components';
|
||||
import { documentationLinks } from '../../services/documentation_links';
|
||||
import {
|
||||
getCreateRoleMappingHref,
|
||||
getEditRoleMappingHref,
|
||||
getEditRoleHref,
|
||||
} from '../../../management_urls';
|
||||
|
||||
interface Props {
|
||||
roleMappingsAPI: RoleMappingsAPI;
|
||||
}
|
||||
|
||||
interface State {
|
||||
loadState: 'loadingApp' | 'loadingTable' | 'permissionDenied' | 'finished';
|
||||
roleMappings: null | RoleMapping[];
|
||||
selectedItems: RoleMapping[];
|
||||
hasCompatibleRealms: boolean;
|
||||
error: any;
|
||||
}
|
||||
|
||||
export class RoleMappingsGridPage extends Component<Props, State> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loadState: 'loadingApp',
|
||||
roleMappings: null,
|
||||
hasCompatibleRealms: true,
|
||||
selectedItems: [],
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.checkPrivileges();
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { loadState, error, roleMappings } = this.state;
|
||||
|
||||
if (loadState === 'permissionDenied') {
|
||||
return <PermissionDenied />;
|
||||
}
|
||||
|
||||
if (loadState === 'loadingApp') {
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<SectionLoading>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.loadingRoleMappingsDescription"
|
||||
defaultMessage="Loading role mappings…"
|
||||
/>
|
||||
</SectionLoading>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const {
|
||||
body: { error: errorTitle, message, statusCode },
|
||||
} = error;
|
||||
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<EuiCallOut
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.loadingRoleMappingsErrorTitle"
|
||||
defaultMessage="Error loading Role mappings"
|
||||
/>
|
||||
}
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
>
|
||||
{statusCode}: {errorTitle} - {message}
|
||||
</EuiCallOut>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadState === 'finished' && roleMappings && roleMappings.length === 0) {
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<EmptyPrompt />
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPageContent>
|
||||
<EuiPageContentHeader>
|
||||
<EuiPageContentHeaderSection>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.roleMappingTitle"
|
||||
defaultMessage="Role Mappings"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiText color="subdued" size="s">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.roleMappingDescription"
|
||||
defaultMessage="Role mappings define which roles are assigned to users from an external identity provider. {learnMoreLink}"
|
||||
values={{
|
||||
learnMoreLink: (
|
||||
<EuiLink
|
||||
href={documentationLinks.getRoleMappingDocUrl()}
|
||||
external={true}
|
||||
target="_blank"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.learnMoreLinkText"
|
||||
defaultMessage="Learn more."
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiPageContentHeaderSection>
|
||||
<EuiPageContentHeaderSection>
|
||||
<EuiButton data-test-subj="createRoleMappingButton" href={getCreateRoleMappingHref()}>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.createRoleMappingButtonLabel"
|
||||
defaultMessage="Create role mapping"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiPageContentHeaderSection>
|
||||
</EuiPageContentHeader>
|
||||
<EuiPageContentBody>
|
||||
<Fragment>
|
||||
{!this.state.hasCompatibleRealms && (
|
||||
<>
|
||||
<NoCompatibleRealms />
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
{this.renderTable()}
|
||||
</Fragment>
|
||||
</EuiPageContentBody>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
||||
private renderTable = () => {
|
||||
const { roleMappings, selectedItems, loadState } = this.state;
|
||||
|
||||
const message =
|
||||
loadState === 'loadingTable' ? (
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.roleMappingTableLoadingMessage"
|
||||
defaultMessage="Loading role mappings…"
|
||||
/>
|
||||
) : (
|
||||
undefined
|
||||
);
|
||||
|
||||
const sorting = {
|
||||
sort: {
|
||||
field: 'name',
|
||||
direction: 'asc' as any,
|
||||
},
|
||||
};
|
||||
|
||||
const pagination = {
|
||||
initialPageSize: 20,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
};
|
||||
|
||||
const selection = {
|
||||
onSelectionChange: (newSelectedItems: RoleMapping[]) => {
|
||||
this.setState({
|
||||
selectedItems: newSelectedItems,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const search = {
|
||||
toolsLeft: selectedItems.length ? (
|
||||
<DeleteProvider roleMappingsAPI={this.props.roleMappingsAPI}>
|
||||
{deleteRoleMappingsPrompt => {
|
||||
return (
|
||||
<EuiButton
|
||||
onClick={() => deleteRoleMappingsPrompt(selectedItems, this.onRoleMappingsDeleted)}
|
||||
color="danger"
|
||||
data-test-subj="bulkDeleteActionButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.deleteRoleMappingButton"
|
||||
defaultMessage="Delete {count, plural, one {role mapping} other {role mappings}}"
|
||||
values={{
|
||||
count: selectedItems.length,
|
||||
}}
|
||||
/>
|
||||
</EuiButton>
|
||||
);
|
||||
}}
|
||||
</DeleteProvider>
|
||||
) : (
|
||||
undefined
|
||||
),
|
||||
toolsRight: (
|
||||
<EuiButton
|
||||
color="secondary"
|
||||
iconType="refresh"
|
||||
onClick={() => this.reloadRoleMappings()}
|
||||
data-test-subj="reloadButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.reloadRoleMappingsButton"
|
||||
defaultMessage="Reload"
|
||||
/>
|
||||
</EuiButton>
|
||||
),
|
||||
box: {
|
||||
incremental: true,
|
||||
},
|
||||
filters: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiInMemoryTable
|
||||
items={roleMappings!}
|
||||
itemId="name"
|
||||
columns={this.getColumnConfig()}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
selection={selection}
|
||||
pagination={pagination}
|
||||
loading={loadState === 'loadingTable'}
|
||||
message={message}
|
||||
isSelectable={true}
|
||||
rowProps={() => {
|
||||
return {
|
||||
'data-test-subj': 'roleMappingRow',
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private getColumnConfig = () => {
|
||||
const config = [
|
||||
{
|
||||
field: 'name',
|
||||
name: i18n.translate('xpack.security.management.roleMappings.nameColumnName', {
|
||||
defaultMessage: 'Name',
|
||||
}),
|
||||
sortable: true,
|
||||
render: (roleMappingName: string) => {
|
||||
return (
|
||||
<EuiLink
|
||||
href={getEditRoleMappingHref(roleMappingName)}
|
||||
data-test-subj="roleMappingName"
|
||||
>
|
||||
{roleMappingName}
|
||||
</EuiLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'roles',
|
||||
name: i18n.translate('xpack.security.management.roleMappings.rolesColumnName', {
|
||||
defaultMessage: 'Roles',
|
||||
}),
|
||||
sortable: true,
|
||||
render: (entry: any, record: RoleMapping) => {
|
||||
const { roles = [], role_templates: roleTemplates = [] } = record;
|
||||
if (roleTemplates.length > 0) {
|
||||
return (
|
||||
<span data-test-subj="roleMappingRoles">
|
||||
{i18n.translate('xpack.security.management.roleMappings.roleTemplates', {
|
||||
defaultMessage:
|
||||
'{templateCount, plural, one{# role template} other {# role templates}} defined',
|
||||
values: {
|
||||
templateCount: roleTemplates.length,
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const roleLinks = roles.map((rolename, index) => {
|
||||
return (
|
||||
<Fragment key={rolename}>
|
||||
<EuiLink href={getEditRoleHref(rolename)}>{rolename}</EuiLink>
|
||||
{index === roles.length - 1 ? null : ', '}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
return <div data-test-subj="roleMappingRoles">{roleLinks}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'enabled',
|
||||
name: i18n.translate('xpack.security.management.roleMappings.enabledColumnName', {
|
||||
defaultMessage: 'Enabled',
|
||||
}),
|
||||
sortable: true,
|
||||
render: (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
return (
|
||||
<EuiBadge data-test-subj="roleMappingEnabled" color="secondary">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.enabledBadge"
|
||||
defaultMessage="Enabled"
|
||||
/>
|
||||
</EuiBadge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiBadge color="hollow" data-test-subj="roleMappingEnabled">
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.roleMappings.disabledBadge"
|
||||
defaultMessage="Disabled"
|
||||
/>
|
||||
</EuiBadge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.security.management.roleMappings.actionsColumnName', {
|
||||
defaultMessage: 'Actions',
|
||||
}),
|
||||
actions: [
|
||||
{
|
||||
render: (record: RoleMapping) => {
|
||||
return (
|
||||
<EuiToolTip
|
||||
content={i18n.translate(
|
||||
'xpack.security.management.roleMappings.actionEditTooltip',
|
||||
{ defaultMessage: 'Edit' }
|
||||
)}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate(
|
||||
'xpack.security.management.roleMappings.actionEditAriaLabel',
|
||||
{
|
||||
defaultMessage: `Edit '{name}'`,
|
||||
values: { name: record.name },
|
||||
}
|
||||
)}
|
||||
iconType="pencil"
|
||||
color="primary"
|
||||
data-test-subj={`editRoleMappingButton-${record.name}`}
|
||||
href={getEditRoleMappingHref(record.name)}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
render: (record: RoleMapping) => {
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<DeleteProvider roleMappingsAPI={this.props.roleMappingsAPI}>
|
||||
{deleteRoleMappingPrompt => {
|
||||
return (
|
||||
<EuiToolTip
|
||||
content={i18n.translate(
|
||||
'xpack.security.management.roleMappings.actionDeleteTooltip',
|
||||
{ defaultMessage: 'Delete' }
|
||||
)}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate(
|
||||
'xpack.security.management.roleMappings.actionDeleteAriaLabel',
|
||||
{
|
||||
defaultMessage: `Delete '{name}'`,
|
||||
values: { name },
|
||||
}
|
||||
)}
|
||||
iconType="trash"
|
||||
color="danger"
|
||||
data-test-subj={`deleteRoleMappingButton-${record.name}`}
|
||||
onClick={() =>
|
||||
deleteRoleMappingPrompt([record], this.onRoleMappingsDeleted)
|
||||
}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
);
|
||||
}}
|
||||
</DeleteProvider>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return config;
|
||||
};
|
||||
|
||||
private onRoleMappingsDeleted = (roleMappings: string[]): void => {
|
||||
if (roleMappings.length) {
|
||||
this.reloadRoleMappings();
|
||||
}
|
||||
};
|
||||
|
||||
private async checkPrivileges() {
|
||||
try {
|
||||
const {
|
||||
canManageRoleMappings,
|
||||
hasCompatibleRealms,
|
||||
} = await this.props.roleMappingsAPI.checkRoleMappingFeatures();
|
||||
|
||||
this.setState({
|
||||
loadState: canManageRoleMappings ? this.state.loadState : 'permissionDenied',
|
||||
hasCompatibleRealms,
|
||||
});
|
||||
|
||||
if (canManageRoleMappings) {
|
||||
this.loadRoleMappings();
|
||||
}
|
||||
} catch (e) {
|
||||
this.setState({ error: e, loadState: 'finished' });
|
||||
}
|
||||
}
|
||||
|
||||
private reloadRoleMappings = () => {
|
||||
this.setState({ roleMappings: [], loadState: 'loadingTable' });
|
||||
this.loadRoleMappings();
|
||||
};
|
||||
|
||||
private loadRoleMappings = async () => {
|
||||
try {
|
||||
const roleMappings = await this.props.roleMappingsAPI.getRoleMappings();
|
||||
this.setState({ roleMappings });
|
||||
} catch (e) {
|
||||
this.setState({ error: e });
|
||||
}
|
||||
|
||||
this.setState({ loadState: 'finished' });
|
||||
};
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { render, unmountComponentAtNode } from 'react-dom';
|
||||
import routes from 'ui/routes';
|
||||
import { I18nContext } from 'ui/i18n';
|
||||
import { npSetup } from 'ui/new_platform';
|
||||
import { RoleMappingsAPI } from '../../../../lib/role_mappings_api';
|
||||
// @ts-ignore
|
||||
import template from './role_mappings.html';
|
||||
import { ROLE_MAPPINGS_PATH } from '../../management_urls';
|
||||
import { getRoleMappingBreadcrumbs } from '../../breadcrumbs';
|
||||
import { RoleMappingsGridPage } from './components';
|
||||
|
||||
routes.when(ROLE_MAPPINGS_PATH, {
|
||||
template,
|
||||
k7Breadcrumbs: getRoleMappingBreadcrumbs,
|
||||
controller($scope) {
|
||||
$scope.$$postDigest(() => {
|
||||
const domNode = document.getElementById('roleMappingsGridReactRoot');
|
||||
|
||||
render(
|
||||
<I18nContext>
|
||||
<RoleMappingsGridPage roleMappingsAPI={new RoleMappingsAPI(npSetup.core.http)} />
|
||||
</I18nContext>,
|
||||
domNode
|
||||
);
|
||||
|
||||
// unmount react on controller destroy
|
||||
$scope.$on('$destroy', () => {
|
||||
if (domNode) {
|
||||
unmountComponentAtNode(domNode);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
<kbn-management-app section="security/role_mappings">
|
||||
<div id="roleMappingsGridReactRoot" />
|
||||
</kbn-management-app>
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links';
|
||||
|
||||
class DocumentationLinksService {
|
||||
private esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`;
|
||||
|
||||
public getRoleMappingDocUrl() {
|
||||
return `${this.esDocBasePath}/mapping-roles.html`;
|
||||
}
|
||||
|
||||
public getRoleMappingAPIDocUrl() {
|
||||
return `${this.esDocBasePath}/security-api-put-role-mapping.html`;
|
||||
}
|
||||
|
||||
public getRoleMappingTemplateDocUrl() {
|
||||
return `${this.esDocBasePath}/security-api-put-role-mapping.html#_role_templates`;
|
||||
}
|
||||
|
||||
public getRoleMappingFieldRulesDocUrl() {
|
||||
return `${this.esDocBasePath}/role-mapping-resources.html#mapping-roles-rule-field`;
|
||||
}
|
||||
}
|
||||
|
||||
export const documentationLinks = new DocumentationLinksService();
|
|
@ -23,6 +23,11 @@ export interface SecurityLicenseFeatures {
|
|||
*/
|
||||
readonly showLinks: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether we show the Role Mappings UI.
|
||||
*/
|
||||
readonly showRoleMappingsManagement: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether we allow users to define document level security in roles.
|
||||
*/
|
||||
|
|
|
@ -17,6 +17,7 @@ describe('license features', function() {
|
|||
showLogin: true,
|
||||
allowLogin: false,
|
||||
showLinks: false,
|
||||
showRoleMappingsManagement: false,
|
||||
allowRoleDocumentLevelSecurity: false,
|
||||
allowRoleFieldLevelSecurity: false,
|
||||
layout: 'error-es-unavailable',
|
||||
|
@ -34,6 +35,7 @@ describe('license features', function() {
|
|||
showLogin: true,
|
||||
allowLogin: false,
|
||||
showLinks: false,
|
||||
showRoleMappingsManagement: false,
|
||||
allowRoleDocumentLevelSecurity: false,
|
||||
allowRoleFieldLevelSecurity: false,
|
||||
layout: 'error-xpack-unavailable',
|
||||
|
@ -63,6 +65,7 @@ describe('license features', function() {
|
|||
"layout": "error-xpack-unavailable",
|
||||
"showLinks": false,
|
||||
"showLogin": true,
|
||||
"showRoleMappingsManagement": false,
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
@ -79,6 +82,7 @@ describe('license features', function() {
|
|||
"linksMessage": "Access is denied because Security is disabled in Elasticsearch.",
|
||||
"showLinks": false,
|
||||
"showLogin": false,
|
||||
"showRoleMappingsManagement": false,
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
@ -87,10 +91,12 @@ describe('license features', function() {
|
|||
}
|
||||
});
|
||||
|
||||
it('should show login page and other security elements, allow RBAC but forbid document level security if license is not platinum or trial.', () => {
|
||||
const mockRawLicense = licensingMock.createLicenseMock();
|
||||
mockRawLicense.hasAtLeast.mockReturnValue(false);
|
||||
mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true });
|
||||
it('should show login page and other security elements, allow RBAC but forbid role mappings and document level security if license is basic.', () => {
|
||||
const mockRawLicense = licensingMock.createLicense({
|
||||
features: { security: { isEnabled: true, isAvailable: true } },
|
||||
});
|
||||
|
||||
const getFeatureSpy = jest.spyOn(mockRawLicense, 'getFeature');
|
||||
|
||||
const serviceSetup = new SecurityLicenseService().setup({
|
||||
license$: of(mockRawLicense),
|
||||
|
@ -99,18 +105,19 @@ describe('license features', function() {
|
|||
showLogin: true,
|
||||
allowLogin: true,
|
||||
showLinks: true,
|
||||
showRoleMappingsManagement: false,
|
||||
allowRoleDocumentLevelSecurity: false,
|
||||
allowRoleFieldLevelSecurity: false,
|
||||
allowRbac: true,
|
||||
});
|
||||
expect(mockRawLicense.getFeature).toHaveBeenCalledTimes(1);
|
||||
expect(mockRawLicense.getFeature).toHaveBeenCalledWith('security');
|
||||
expect(getFeatureSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getFeatureSpy).toHaveBeenCalledWith('security');
|
||||
});
|
||||
|
||||
it('should not show login page or other security elements if security is disabled in Elasticsearch.', () => {
|
||||
const mockRawLicense = licensingMock.createLicenseMock();
|
||||
mockRawLicense.hasAtLeast.mockReturnValue(false);
|
||||
mockRawLicense.getFeature.mockReturnValue({ isEnabled: false, isAvailable: true });
|
||||
const mockRawLicense = licensingMock.createLicense({
|
||||
features: { security: { isEnabled: false, isAvailable: true } },
|
||||
});
|
||||
|
||||
const serviceSetup = new SecurityLicenseService().setup({
|
||||
license$: of(mockRawLicense),
|
||||
|
@ -119,6 +126,7 @@ describe('license features', function() {
|
|||
showLogin: false,
|
||||
allowLogin: false,
|
||||
showLinks: false,
|
||||
showRoleMappingsManagement: false,
|
||||
allowRoleDocumentLevelSecurity: false,
|
||||
allowRoleFieldLevelSecurity: false,
|
||||
allowRbac: false,
|
||||
|
@ -126,12 +134,11 @@ describe('license features', function() {
|
|||
});
|
||||
});
|
||||
|
||||
it('should allow to login, allow RBAC and document level security if license >= platinum', () => {
|
||||
const mockRawLicense = licensingMock.createLicenseMock();
|
||||
mockRawLicense.hasAtLeast.mockImplementation(license => {
|
||||
return license === 'trial' || license === 'platinum' || license === 'enterprise';
|
||||
it('should allow role mappings, but not DLS/FLS if license = gold', () => {
|
||||
const mockRawLicense = licensingMock.createLicense({
|
||||
license: { mode: 'gold', type: 'gold' },
|
||||
features: { security: { isEnabled: true, isAvailable: true } },
|
||||
});
|
||||
mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true });
|
||||
|
||||
const serviceSetup = new SecurityLicenseService().setup({
|
||||
license$: of(mockRawLicense),
|
||||
|
@ -140,6 +147,27 @@ describe('license features', function() {
|
|||
showLogin: true,
|
||||
allowLogin: true,
|
||||
showLinks: true,
|
||||
showRoleMappingsManagement: true,
|
||||
allowRoleDocumentLevelSecurity: false,
|
||||
allowRoleFieldLevelSecurity: false,
|
||||
allowRbac: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow to login, allow RBAC, allow role mappings, and document level security if license >= platinum', () => {
|
||||
const mockRawLicense = licensingMock.createLicense({
|
||||
license: { mode: 'platinum', type: 'platinum' },
|
||||
features: { security: { isEnabled: true, isAvailable: true } },
|
||||
});
|
||||
|
||||
const serviceSetup = new SecurityLicenseService().setup({
|
||||
license$: of(mockRawLicense),
|
||||
});
|
||||
expect(serviceSetup.license.getFeatures()).toEqual({
|
||||
showLogin: true,
|
||||
allowLogin: true,
|
||||
showLinks: true,
|
||||
showRoleMappingsManagement: true,
|
||||
allowRoleDocumentLevelSecurity: true,
|
||||
allowRoleFieldLevelSecurity: true,
|
||||
allowRbac: true,
|
||||
|
|
|
@ -70,6 +70,7 @@ export class SecurityLicenseService {
|
|||
showLogin: true,
|
||||
allowLogin: false,
|
||||
showLinks: false,
|
||||
showRoleMappingsManagement: false,
|
||||
allowRoleDocumentLevelSecurity: false,
|
||||
allowRoleFieldLevelSecurity: false,
|
||||
allowRbac: false,
|
||||
|
@ -85,6 +86,7 @@ export class SecurityLicenseService {
|
|||
showLogin: false,
|
||||
allowLogin: false,
|
||||
showLinks: false,
|
||||
showRoleMappingsManagement: false,
|
||||
allowRoleDocumentLevelSecurity: false,
|
||||
allowRoleFieldLevelSecurity: false,
|
||||
allowRbac: false,
|
||||
|
@ -92,11 +94,13 @@ export class SecurityLicenseService {
|
|||
};
|
||||
}
|
||||
|
||||
const showRoleMappingsManagement = rawLicense.hasAtLeast('gold');
|
||||
const isLicensePlatinumOrBetter = rawLicense.hasAtLeast('platinum');
|
||||
return {
|
||||
showLogin: true,
|
||||
allowLogin: true,
|
||||
showLinks: true,
|
||||
showRoleMappingsManagement,
|
||||
// Only platinum and trial licenses are compliant with field- and document-level security.
|
||||
allowRoleDocumentLevelSecurity: isLicensePlatinumOrBetter,
|
||||
allowRoleFieldLevelSecurity: isLicensePlatinumOrBetter,
|
||||
|
|
|
@ -12,3 +12,10 @@ export { FeaturesPrivileges } from './features_privileges';
|
|||
export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges';
|
||||
export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role';
|
||||
export { KibanaPrivileges } from './kibana_privileges';
|
||||
export {
|
||||
InlineRoleTemplate,
|
||||
StoredRoleTemplate,
|
||||
InvalidRoleTemplate,
|
||||
RoleTemplate,
|
||||
RoleMapping,
|
||||
} from './role_mapping';
|
||||
|
|
55
x-pack/plugins/security/common/model/role_mapping.ts
Normal file
55
x-pack/plugins/security/common/model/role_mapping.ts
Normal file
|
@ -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.
|
||||
*/
|
||||
|
||||
interface RoleMappingAnyRule {
|
||||
any: RoleMappingRule[];
|
||||
}
|
||||
|
||||
interface RoleMappingAllRule {
|
||||
all: RoleMappingRule[];
|
||||
}
|
||||
|
||||
interface RoleMappingFieldRule {
|
||||
field: Record<string, any>;
|
||||
}
|
||||
|
||||
interface RoleMappingExceptRule {
|
||||
except: RoleMappingRule;
|
||||
}
|
||||
|
||||
type RoleMappingRule =
|
||||
| RoleMappingAnyRule
|
||||
| RoleMappingAllRule
|
||||
| RoleMappingFieldRule
|
||||
| RoleMappingExceptRule;
|
||||
|
||||
type RoleTemplateFormat = 'string' | 'json';
|
||||
|
||||
export interface InlineRoleTemplate {
|
||||
template: { source: string };
|
||||
format?: RoleTemplateFormat;
|
||||
}
|
||||
|
||||
export interface StoredRoleTemplate {
|
||||
template: { id: string };
|
||||
format?: RoleTemplateFormat;
|
||||
}
|
||||
|
||||
export interface InvalidRoleTemplate {
|
||||
template: string;
|
||||
format?: RoleTemplateFormat;
|
||||
}
|
||||
|
||||
export type RoleTemplate = InlineRoleTemplate | StoredRoleTemplate | InvalidRoleTemplate;
|
||||
|
||||
export interface RoleMapping {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
roles?: string[];
|
||||
role_templates?: RoleTemplate[];
|
||||
rules: RoleMappingRule | {};
|
||||
metadata: Record<string, any>;
|
||||
}
|
|
@ -573,4 +573,64 @@ export function elasticsearchClientPlugin(Client: any, config: unknown, componen
|
|||
fmt: '/_security/delegate_pki',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieves all configured role mappings.
|
||||
*
|
||||
* @returns {{ [roleMappingName]: { enabled: boolean; roles: string[]; rules: Record<string, any>} }}
|
||||
*/
|
||||
shield.getRoleMappings = ca({
|
||||
method: 'GET',
|
||||
urls: [
|
||||
{
|
||||
fmt: '/_security/role_mapping',
|
||||
},
|
||||
{
|
||||
fmt: '/_security/role_mapping/<%=name%>',
|
||||
req: {
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Saves the specified role mapping.
|
||||
*/
|
||||
shield.saveRoleMapping = ca({
|
||||
method: 'POST',
|
||||
needBody: true,
|
||||
urls: [
|
||||
{
|
||||
fmt: '/_security/role_mapping/<%=name%>',
|
||||
req: {
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes the specified role mapping.
|
||||
*/
|
||||
shield.deleteRoleMapping = ca({
|
||||
method: 'DELETE',
|
||||
urls: [
|
||||
{
|
||||
fmt: '/_security/role_mapping/<%=name%>',
|
||||
req: {
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { defineAuthorizationRoutes } from './authorization';
|
|||
import { defineApiKeysRoutes } from './api_keys';
|
||||
import { defineIndicesRoutes } from './indices';
|
||||
import { defineUsersRoutes } from './users';
|
||||
import { defineRoleMappingRoutes } from './role_mapping';
|
||||
|
||||
/**
|
||||
* Describes parameters used to define HTTP routes.
|
||||
|
@ -35,4 +36,5 @@ export function defineRoutes(params: RouteDefinitionParams) {
|
|||
defineApiKeysRoutes(params);
|
||||
defineIndicesRoutes(params);
|
||||
defineUsersRoutes(params);
|
||||
defineRoleMappingRoutes(params);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 { routeDefinitionParamsMock } from '../index.mock';
|
||||
import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks';
|
||||
import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server';
|
||||
import { LICENSE_CHECK_STATE } from '../../../../licensing/server';
|
||||
import { defineRoleMappingDeleteRoutes } from './delete';
|
||||
|
||||
describe('DELETE role mappings', () => {
|
||||
it('allows a role mapping to be deleted', async () => {
|
||||
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
|
||||
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
|
||||
mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({ acknowledged: true });
|
||||
|
||||
defineRoleMappingDeleteRoutes(mockRouteDefinitionParams);
|
||||
|
||||
const [[, handler]] = mockRouteDefinitionParams.router.delete.mock.calls;
|
||||
|
||||
const name = 'mapping1';
|
||||
|
||||
const headers = { authorization: 'foo' };
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
method: 'delete',
|
||||
path: `/internal/security/role_mapping/${name}`,
|
||||
params: { name },
|
||||
headers,
|
||||
});
|
||||
const mockContext = ({
|
||||
licensing: {
|
||||
license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) },
|
||||
},
|
||||
} as unknown) as RequestHandlerContext;
|
||||
|
||||
const response = await handler(mockContext, mockRequest, kibanaResponseFactory);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.payload).toEqual({ acknowledged: true });
|
||||
expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest);
|
||||
expect(
|
||||
mockScopedClusterClient.callAsCurrentUser
|
||||
).toHaveBeenCalledWith('shield.deleteRoleMapping', { name });
|
||||
});
|
||||
|
||||
describe('failure', () => {
|
||||
it('returns result of license check', async () => {
|
||||
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
|
||||
|
||||
defineRoleMappingDeleteRoutes(mockRouteDefinitionParams);
|
||||
|
||||
const [[, handler]] = mockRouteDefinitionParams.router.delete.mock.calls;
|
||||
|
||||
const name = 'mapping1';
|
||||
|
||||
const headers = { authorization: 'foo' };
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
method: 'delete',
|
||||
path: `/internal/security/role_mapping/${name}`,
|
||||
params: { name },
|
||||
headers,
|
||||
});
|
||||
const mockContext = ({
|
||||
licensing: {
|
||||
license: {
|
||||
check: jest.fn().mockReturnValue({
|
||||
state: LICENSE_CHECK_STATE.Invalid,
|
||||
message: 'test forbidden message',
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown) as RequestHandlerContext;
|
||||
|
||||
const response = await handler(mockContext, mockRequest, kibanaResponseFactory);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.payload).toEqual({ message: 'test forbidden message' });
|
||||
expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
40
x-pack/plugins/security/server/routes/role_mapping/delete.ts
Normal file
40
x-pack/plugins/security/server/routes/role_mapping/delete.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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 { schema } from '@kbn/config-schema';
|
||||
import { createLicensedRouteHandler } from '../licensed_route_handler';
|
||||
import { wrapError } from '../../errors';
|
||||
import { RouteDefinitionParams } from '..';
|
||||
|
||||
export function defineRoleMappingDeleteRoutes(params: RouteDefinitionParams) {
|
||||
const { clusterClient, router } = params;
|
||||
|
||||
router.delete(
|
||||
{
|
||||
path: '/internal/security/role_mapping/{name}',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
name: schema.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
createLicensedRouteHandler(async (context, request, response) => {
|
||||
try {
|
||||
const deleteResponse = await clusterClient
|
||||
.asScoped(request)
|
||||
.callAsCurrentUser('shield.deleteRoleMapping', {
|
||||
name: request.params.name,
|
||||
});
|
||||
return response.ok({ body: deleteResponse });
|
||||
} catch (error) {
|
||||
const wrappedError = wrapError(error);
|
||||
return response.customError({
|
||||
body: wrappedError,
|
||||
statusCode: wrappedError.output.statusCode,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -0,0 +1,248 @@
|
|||
/*
|
||||
* 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 { routeDefinitionParamsMock } from '../index.mock';
|
||||
import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks';
|
||||
import {
|
||||
kibanaResponseFactory,
|
||||
RequestHandlerContext,
|
||||
IClusterClient,
|
||||
} from '../../../../../../src/core/server';
|
||||
import { LICENSE_CHECK_STATE, LicenseCheck } from '../../../../licensing/server';
|
||||
import { defineRoleMappingFeatureCheckRoute } from './feature_check';
|
||||
|
||||
interface TestOptions {
|
||||
licenseCheckResult?: LicenseCheck;
|
||||
canManageRoleMappings?: boolean;
|
||||
nodeSettingsResponse?: Record<string, any>;
|
||||
xpackUsageResponse?: Record<string, any>;
|
||||
internalUserClusterClientImpl?: IClusterClient['callAsInternalUser'];
|
||||
asserts: { statusCode: number; result?: Record<string, any> };
|
||||
}
|
||||
|
||||
const defaultXpackUsageResponse = {
|
||||
security: {
|
||||
realms: {
|
||||
native: {
|
||||
available: true,
|
||||
enabled: true,
|
||||
},
|
||||
pki: {
|
||||
available: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const getDefaultInternalUserClusterClientImpl = (
|
||||
nodeSettingsResponse: TestOptions['nodeSettingsResponse'],
|
||||
xpackUsageResponse: TestOptions['xpackUsageResponse']
|
||||
) =>
|
||||
((async (endpoint: string, clientParams: Record<string, any>) => {
|
||||
if (!clientParams) throw new TypeError('expected clientParams');
|
||||
|
||||
if (endpoint === 'nodes.info') {
|
||||
return nodeSettingsResponse;
|
||||
}
|
||||
|
||||
if (endpoint === 'transport.request') {
|
||||
if (clientParams.path === '/_xpack/usage') {
|
||||
return xpackUsageResponse;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`unexpected endpoint: ${endpoint}`);
|
||||
}) as unknown) as TestOptions['internalUserClusterClientImpl'];
|
||||
|
||||
describe('GET role mappings feature check', () => {
|
||||
const getFeatureCheckTest = (
|
||||
description: string,
|
||||
{
|
||||
licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid },
|
||||
canManageRoleMappings = true,
|
||||
nodeSettingsResponse = {},
|
||||
xpackUsageResponse = defaultXpackUsageResponse,
|
||||
internalUserClusterClientImpl = getDefaultInternalUserClusterClientImpl(
|
||||
nodeSettingsResponse,
|
||||
xpackUsageResponse
|
||||
),
|
||||
asserts,
|
||||
}: TestOptions
|
||||
) => {
|
||||
test(description, async () => {
|
||||
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
|
||||
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
|
||||
mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
mockRouteDefinitionParams.clusterClient.callAsInternalUser.mockImplementation(
|
||||
internalUserClusterClientImpl
|
||||
);
|
||||
|
||||
mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method, payload) => {
|
||||
if (method === 'shield.hasPrivileges') {
|
||||
return {
|
||||
has_all_requested: canManageRoleMappings,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
defineRoleMappingFeatureCheckRoute(mockRouteDefinitionParams);
|
||||
const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls;
|
||||
|
||||
const headers = { authorization: 'foo' };
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
method: 'get',
|
||||
path: `/internal/security/_check_role_mapping_features`,
|
||||
headers,
|
||||
});
|
||||
const mockContext = ({
|
||||
licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } },
|
||||
} as unknown) as RequestHandlerContext;
|
||||
|
||||
const response = await handler(mockContext, mockRequest, kibanaResponseFactory);
|
||||
expect(response.status).toBe(asserts.statusCode);
|
||||
expect(response.payload).toEqual(asserts.result);
|
||||
|
||||
expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic');
|
||||
});
|
||||
};
|
||||
|
||||
getFeatureCheckTest('allows both script types with the default settings', {
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
canManageRoleMappings: true,
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: true,
|
||||
hasCompatibleRealms: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
getFeatureCheckTest('allows both script types when explicitly enabled', {
|
||||
nodeSettingsResponse: {
|
||||
nodes: {
|
||||
someNodeId: {
|
||||
settings: {
|
||||
script: {
|
||||
allowed_types: ['stored', 'inline'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
canManageRoleMappings: true,
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: true,
|
||||
hasCompatibleRealms: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
getFeatureCheckTest('disallows stored scripts when disabled', {
|
||||
nodeSettingsResponse: {
|
||||
nodes: {
|
||||
someNodeId: {
|
||||
settings: {
|
||||
script: {
|
||||
allowed_types: ['inline'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
canManageRoleMappings: true,
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: false,
|
||||
hasCompatibleRealms: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
getFeatureCheckTest('disallows inline scripts when disabled', {
|
||||
nodeSettingsResponse: {
|
||||
nodes: {
|
||||
someNodeId: {
|
||||
settings: {
|
||||
script: {
|
||||
allowed_types: ['stored'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
canManageRoleMappings: true,
|
||||
canUseInlineScripts: false,
|
||||
canUseStoredScripts: true,
|
||||
hasCompatibleRealms: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
getFeatureCheckTest('indicates incompatible realms when only native and file are enabled', {
|
||||
xpackUsageResponse: {
|
||||
security: {
|
||||
realms: {
|
||||
native: {
|
||||
available: true,
|
||||
enabled: true,
|
||||
},
|
||||
file: {
|
||||
available: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
canManageRoleMappings: true,
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: true,
|
||||
hasCompatibleRealms: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
getFeatureCheckTest('indicates canManageRoleMappings=false for users without `manage_security`', {
|
||||
canManageRoleMappings: false,
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
canManageRoleMappings: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
getFeatureCheckTest(
|
||||
'falls back to allowing both script types if there is an error retrieving node settings',
|
||||
{
|
||||
internalUserClusterClientImpl: (() => {
|
||||
return Promise.reject(new Error('something bad happened'));
|
||||
}) as TestOptions['internalUserClusterClientImpl'],
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
canManageRoleMappings: true,
|
||||
canUseInlineScripts: true,
|
||||
canUseStoredScripts: true,
|
||||
hasCompatibleRealms: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue