[Roles] Use Query Roles API for Role Management grid screen (#194630)

Closes https://github.com/elastic/kibana/issues/186266

## Release notes

Enhanced Role management to manage larger number of roles by adding
server side filtering, pagination and querying.

## Summary
- Replaced the usage of Get Roles API with Query Role API
- Added server side pagination and filtering with a maximum limit of
10000 keys (default for max results on index). Added new label to
indicate that we show only 10k results.
- Search box replicates client side implementation by only filtering on
Role names.

### Run locally
Start ES ~with the JVM option to enable this feature~:
```
yarn es snapshot --license=trial
```
Start Kibana normally
```
yarn start --no-base-path
```

Navigate to Stack Management > Roles and verify the same behavior as the
screen recording below

### Screen recording


https://github.com/user-attachments/assets/a447e7df-8aa1-4044-a6b2-0aafe56844a9



## Technical notes
- Client side EuiInMemory table has been replaced by EuiSearchBar,
EuiBasicTable and Filters
- One new Kibana endpoint added
    -  `roles/_query` 
- Replicates existing get_role endpoint by being public and added to
Open API spec
- Extra logic to handle previously UI only filter to show/hide reserved
roles
- Parse the query to construct the correct DSL if the filter is present
- Update Get All Roles by Space internal API to use the Query Role and
filter by space id using query DSL.

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sid 2025-01-29 15:38:03 +01:00 committed by GitHub
parent 4da814d138
commit 66dab0ae0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1374 additions and 478 deletions

View file

@ -40769,6 +40769,80 @@
]
}
},
"/api/security/role/_query": {
"post": {
"operationId": "post-security-role-query",
"parameters": [
{
"description": "A required header to protect against CSRF attacks",
"in": "header",
"name": "kbn-xsrf",
"required": true,
"schema": {
"example": "true",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"filters": {
"additionalProperties": false,
"properties": {
"showReservedRoles": {
"type": "boolean"
}
},
"type": "object"
},
"from": {
"type": "number"
},
"query": {
"type": "string"
},
"size": {
"type": "number"
},
"sort": {
"additionalProperties": false,
"properties": {
"direction": {
"enum": [
"asc",
"desc"
],
"type": "string"
},
"field": {
"type": "string"
}
},
"required": [
"field",
"direction"
],
"type": "object"
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Indicates a successful call."
}
},
"summary": "Query roles",
"tags": []
}
},
"/api/security/role/{name}": {
"delete": {
"operationId": "delete-security-role-name",

View file

@ -40769,6 +40769,80 @@
]
}
},
"/api/security/role/_query": {
"post": {
"operationId": "post-security-role-query",
"parameters": [
{
"description": "A required header to protect against CSRF attacks",
"in": "header",
"name": "kbn-xsrf",
"required": true,
"schema": {
"example": "true",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"filters": {
"additionalProperties": false,
"properties": {
"showReservedRoles": {
"type": "boolean"
}
},
"type": "object"
},
"from": {
"type": "number"
},
"query": {
"type": "string"
},
"size": {
"type": "number"
},
"sort": {
"additionalProperties": false,
"properties": {
"direction": {
"enum": [
"asc",
"desc"
],
"type": "string"
},
"field": {
"type": "string"
}
},
"required": [
"field",
"direction"
],
"type": "object"
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Indicates a successful call."
}
},
"summary": "Query roles",
"tags": []
}
},
"/api/security/role/{name}": {
"delete": {
"operationId": "delete-security-role-name",

View file

@ -37827,6 +37827,56 @@ paths:
tags:
- roles
x-beta: true
/api/security/role/_query:
post:
operationId: post-security-role-query
parameters:
- description: A required header to protect against CSRF attacks
in: header
name: kbn-xsrf
required: true
schema:
example: 'true'
type: string
requestBody:
content:
application/json; Elastic-Api-Version=2023-10-31:
schema:
additionalProperties: false
type: object
properties:
filters:
additionalProperties: false
type: object
properties:
showReservedRoles:
type: boolean
from:
type: number
query:
type: string
size:
type: number
sort:
additionalProperties: false
type: object
properties:
direction:
enum:
- asc
- desc
type: string
field:
type: string
required:
- field
- direction
responses:
'200':
description: Indicates a successful call.
summary: Query roles
tags: []
x-beta: true
/api/security/role/{name}:
delete:
operationId: delete-security-role-name

View file

@ -40299,6 +40299,55 @@ paths:
summary: Get all roles
tags:
- roles
/api/security/role/_query:
post:
operationId: post-security-role-query
parameters:
- description: A required header to protect against CSRF attacks
in: header
name: kbn-xsrf
required: true
schema:
example: 'true'
type: string
requestBody:
content:
application/json; Elastic-Api-Version=2023-10-31:
schema:
additionalProperties: false
type: object
properties:
filters:
additionalProperties: false
type: object
properties:
showReservedRoles:
type: boolean
from:
type: number
query:
type: string
size:
type: number
sort:
additionalProperties: false
type: object
properties:
direction:
enum:
- asc
- desc
type: string
field:
type: string
required:
- field
- direction
responses:
'200':
description: Indicates a successful call.
summary: Query roles
tags: []
/api/security/role/{name}:
delete:
operationId: delete-security-role-name

View file

@ -12,6 +12,8 @@ export type {
AuthenticationProvider,
} from './src/authentication';
export type {
QueryRolesRole,
QueryRolesResult,
RemoteClusterPrivilege,
Role,
RoleIndexPrivilege,

View file

@ -8,6 +8,8 @@
export type { FeaturesPrivileges } from './features_privileges';
export type { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from './raw_kibana_privileges';
export type {
QueryRolesRole,
QueryRolesResult,
RemoteClusterPrivilege,
Role,
RoleKibanaPrivilege,

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { estypes } from '@elastic/elasticsearch';
import type { FeaturesPrivileges } from './features_privileges';
export interface RoleIndexPrivilege {
@ -55,3 +57,11 @@ export interface Role {
_transform_error?: string[];
_unrecognized_applications?: string[];
}
export type QueryRolesRole = estypes.SecurityQueryRoleQueryRole;
export interface QueryRolesResult {
roles: Role[];
count: number;
total: number;
}

View file

@ -12,5 +12,6 @@ export const rolesAPIClientMock = {
deleteRole: jest.fn(),
saveRole: jest.fn(),
bulkUpdateRoles: jest.fn(),
queryRoles: jest.fn(),
}),
};

View file

@ -5,13 +5,26 @@
* 2.0.
*/
import type { Criteria } from '@elastic/eui';
import type { HttpStart } from '@kbn/core/public';
import type { QueryRolesResult } from '@kbn/security-plugin-types-common';
import type { BulkUpdatePayload, BulkUpdateRoleResponse } from '@kbn/security-plugin-types-public';
import type { Role, RoleIndexPrivilege, RoleRemoteIndexPrivilege } from '../../../common';
import { API_VERSIONS } from '../../../common/constants';
import { copyRole } from '../../../common/model';
export interface QueryRoleParams {
query: string;
from: number;
size: number;
filters?: {
showReservedRoles?: boolean;
};
sort: Criteria<Role>['sort'];
}
const version = API_VERSIONS.roles.public.v1;
export class RolesAPIClient {
@ -24,6 +37,13 @@ export class RolesAPIClient {
});
};
public queryRoles = async (params?: QueryRoleParams) => {
return await this.http.post<QueryRolesResult>(`/api/security/role/_query`, {
version,
body: JSON.stringify(params || {}),
});
};
public getRole = async (roleName: string) => {
return await this.http.get<Role>(`/api/security/role/${encodeURIComponent(roleName)}`, {
version,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiIcon, EuiInMemoryTable } from '@elastic/eui';
import { EuiIcon } from '@elastic/eui';
import type { ReactWrapper } from 'enzyme';
import React from 'react';
@ -51,36 +51,40 @@ describe('<RolesGridPage />', () => {
history.createHref.mockImplementation((location) => location.pathname!);
apiClientMock = rolesAPIClientMock.create();
apiClientMock.getRoles.mockResolvedValue([
{
name: 'test-role-1',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
},
{
name: 'test-role-with-description',
description: 'role-description',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
},
{
name: 'reserved-role',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
metadata: { _reserved: true },
},
{
name: 'disabled-role',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
transient_metadata: { enabled: false },
},
{
name: 'special%chars%role',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
},
]);
apiClientMock.queryRoles.mockResolvedValue({
total: 5,
count: 5,
roles: [
{
name: 'test-role-1',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
},
{
name: 'test-role-with-description',
description: 'role-description',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
},
{
name: 'reserved-role',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
metadata: { _reserved: true },
},
{
name: 'disabled-role',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
transient_metadata: { enabled: false },
},
{
name: 'special%chars%role',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
},
],
});
});
it(`renders reserved roles as such`, async () => {
@ -130,7 +134,7 @@ describe('<RolesGridPage />', () => {
});
it('renders permission denied if required', async () => {
apiClientMock.getRoles.mockRejectedValue(mock403());
apiClientMock.queryRoles.mockRejectedValue(mock403());
const wrapper = mountWithIntl(
<RolesGridPage
@ -195,248 +199,6 @@ describe('<RolesGridPage />', () => {
);
});
it('hides reserved roles when instructed to', async () => {
const wrapper = mountWithIntl(
<RolesGridPage
rolesAPIClient={apiClientMock}
history={history}
notifications={notifications}
i18n={i18n}
buildFlavor={'traditional'}
analytics={analytics}
theme={theme}
userProfile={userProfile}
/>
);
const initialIconCount = wrapper.find(EuiIcon).length;
await waitForRender(wrapper, (updatedWrapper) => {
return updatedWrapper.find(EuiIcon).length > initialIconCount;
});
expect(wrapper.find(EuiInMemoryTable).props().items).toEqual([
{
name: 'test-role-1',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
spaces: [],
feature: {},
},
],
},
{
name: 'test-role-with-description',
description: 'role-description',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
spaces: [],
feature: {},
},
],
},
{
name: 'reserved-role',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
spaces: [],
feature: {},
},
],
metadata: {
_reserved: true,
},
},
{
name: 'disabled-role',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
spaces: [],
feature: {},
},
],
transient_metadata: {
enabled: false,
},
},
{
name: 'special%chars%role',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
spaces: [],
feature: {},
},
],
},
]);
findTestSubject(wrapper, 'showReservedRolesSwitch').simulate('click');
expect(wrapper.find(EuiInMemoryTable).props().items).toEqual([
{
name: 'test-role-1',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
},
{
name: 'test-role-with-description',
description: 'role-description',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
},
{
name: 'disabled-role',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
transient_metadata: { enabled: false },
},
{
name: 'special%chars%role',
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [{ base: [], spaces: [], feature: {} }],
},
]);
});
it('sorts columns on clicking the column header', async () => {
const wrapper = mountWithIntl(
<RolesGridPage
rolesAPIClient={apiClientMock}
history={history}
notifications={notifications}
i18n={i18n}
buildFlavor={'traditional'}
analytics={analytics}
theme={theme}
userProfile={userProfile}
/>
);
const initialIconCount = wrapper.find(EuiIcon).length;
await waitForRender(wrapper, (updatedWrapper) => {
return updatedWrapper.find(EuiIcon).length > initialIconCount;
});
expect(wrapper.find(EuiInMemoryTable).props().items).toEqual([
{
name: 'test-role-1',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
spaces: [],
feature: {},
},
],
},
{
name: 'test-role-with-description',
description: 'role-description',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
spaces: [],
feature: {},
},
],
},
{
name: 'reserved-role',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
spaces: [],
feature: {},
},
],
metadata: {
_reserved: true,
},
},
{
name: 'disabled-role',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
spaces: [],
feature: {},
},
],
transient_metadata: {
enabled: false,
},
},
{
name: 'special%chars%role',
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
base: [],
spaces: [],
feature: {},
},
],
},
]);
findTestSubject(wrapper, 'tableHeaderCell_name_0').simulate('click');
const firstRowElement = findTestSubject(wrapper, 'roleRowName').first();
expect(firstRowElement.text()).toBe('disabled-role');
});
it('hides controls when `readOnly` is enabled', async () => {
const wrapper = mountWithIntl(
<RolesGridPage

View file

@ -5,13 +5,20 @@
* 2.0.
*/
import type { EuiBasicTableColumn, EuiSwitchEvent } from '@elastic/eui';
import type {
Criteria,
CriteriaWithPagination,
EuiBasicTableColumn,
EuiSearchBarOnChangeArgs,
EuiSwitchEvent,
Query,
} from '@elastic/eui';
import {
EuiBasicTable,
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiLink,
EuiSearchBar,
EuiSpacer,
@ -28,6 +35,7 @@ import type { NotificationsStart, ScopedHistory } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public';
import type { QueryRolesResult } from '@kbn/security-plugin-types-common';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import type { PublicMethodsOf } from '@kbn/utility-types';
@ -54,18 +62,33 @@ export interface Props extends StartServices {
cloudOrgUrl?: string;
}
interface RolesTableState {
query: Query;
sort: Criteria<Role>['sort'];
from: number;
size: number;
filters: {
showReservedRoles?: boolean;
};
}
const getRoleManagementHref = (action: 'edit' | 'clone', roleName?: string) => {
return `/${action}${roleName ? `/${encodeURIComponent(roleName)}` : ''}`;
};
const getVisibleRoles = (roles: Role[], filter: string, includeReservedRoles: boolean) => {
return roles.filter((role) => {
const normalized = `${role.name}`.toLowerCase();
const normalizedQuery = filter.toLowerCase();
return (
normalized.indexOf(normalizedQuery) !== -1 && (includeReservedRoles || !isRoleReserved(role))
);
});
const MAX_PAGINATED_ITEMS = 10000;
const DEFAULT_TABLE_STATE = {
query: EuiSearchBar.Query.MATCH_ALL,
sort: {
field: 'name' as const,
direction: 'asc' as const,
},
from: 0,
size: 25,
filters: {
showReservedRoles: true,
},
};
export const RolesGridPage: FC<Props> = ({
@ -77,25 +100,28 @@ export const RolesGridPage: FC<Props> = ({
cloudOrgUrl,
...startServices
}) => {
const [roles, setRoles] = useState<Role[]>([]);
const [visibleRoles, setVisibleRoles] = useState<Role[]>([]);
const [rolesResponse, setRolesResponse] = useState<QueryRolesResult>({} as QueryRolesResult);
const [selection, setSelection] = useState<Role[]>([]);
const [filter, setFilter] = useState<string>('');
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState<boolean>(false);
const [permissionDenied, setPermissionDenied] = useState<boolean>(false);
const [includeReservedRoles, setIncludeReservedRoles] = useState<boolean>(true);
const [isLoading, setIsLoading] = useState<boolean>(false);
useEffect(() => {
loadRoles();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const [tableState, setTableState] = useState<RolesTableState>(DEFAULT_TABLE_STATE);
const loadRoles = async (tableStateArgs: RolesTableState) => {
const queryText = tableStateArgs.query.text;
const requestBody = {
...tableStateArgs,
...(tableStateArgs.sort ? { sort: tableStateArgs.sort } : DEFAULT_TABLE_STATE.sort),
query: queryText,
};
const loadRoles = async () => {
try {
setIsLoading(true);
const rolesFromApi = await rolesAPIClient.getRoles();
setRoles(rolesFromApi);
setVisibleRoles(getVisibleRoles(rolesFromApi, filter, includeReservedRoles));
const rolesFromApi = await rolesAPIClient.queryRoles(requestBody);
setRolesResponse(rolesFromApi);
} catch (e) {
if (_.get(e, 'body.statusCode') === 403) {
setPermissionDenied(true);
@ -112,9 +138,19 @@ export const RolesGridPage: FC<Props> = ({
}
};
useEffect(() => {
loadRoles(DEFAULT_TABLE_STATE);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onIncludeReservedRolesChange = (e: EuiSwitchEvent) => {
setIncludeReservedRoles(e.target.checked);
setVisibleRoles(getVisibleRoles(roles, filter, e.target.checked));
const newTableStateArgs = {
...tableState,
filters: {
showReservedRoles: e.target.checked,
},
};
setTableState(newTableStateArgs);
loadRoles(newTableStateArgs);
};
const getRoleStatusBadges = (role: Role) => {
@ -162,7 +198,7 @@ export const RolesGridPage: FC<Props> = ({
const handleDelete = () => {
setSelection([]);
setShowDeleteConfirmation(false);
loadRoles();
loadRoles(tableState);
};
const deleteOneRole = (roleToDelete: Role) => {
@ -203,13 +239,35 @@ export const RolesGridPage: FC<Props> = ({
defaultMessage="Show reserved roles"
/>
}
checked={includeReservedRoles}
checked={tableState.filters.showReservedRoles ?? true}
onChange={onIncludeReservedRolesChange}
/>
);
}
};
const onTableChange = ({ page, sort }: CriteriaWithPagination<Role>) => {
const newState = {
...tableState,
from: page?.index! * page?.size!,
size: page?.size!,
sort: sort ?? tableState.sort,
};
setTableState(newState);
loadRoles(newState);
};
const onSearchChange = (args: EuiSearchBarOnChangeArgs) => {
if (!args.error) {
const newState = {
...tableState,
query: args.query,
};
setTableState(newState);
loadRoles(newState);
}
};
const getColumnConfig = (): Array<EuiBasicTableColumn<Role>> => {
const config: Array<EuiBasicTableColumn<Role>> = [
{
@ -234,7 +292,7 @@ export const RolesGridPage: FC<Props> = ({
name: i18n.translate('xpack.security.management.roles.descriptionColumnName', {
defaultMessage: 'Role Description',
}),
sortable: true,
sortable: false,
truncateText: { lines: 3 },
render: (description: string, record: Role) => (
<EuiToolTip position="top" content={description} display="block">
@ -251,7 +309,7 @@ export const RolesGridPage: FC<Props> = ({
name: i18n.translate('xpack.security.management.roles.statusColumnName', {
defaultMessage: 'Status',
}),
sortable: (role: Role) => isRoleEnabled(role) && !isRoleDeprecated(role),
sortable: false,
render: (_metadata: Role['metadata'], record: Role) => getRoleStatusBadges(record),
});
}
@ -331,6 +389,19 @@ export const RolesGridPage: FC<Props> = ({
setShowDeleteConfirmation(false);
};
const tableItems = rolesResponse.roles ?? [];
const totalItemCount = rolesResponse.total ?? 0;
const displayedItemCount = Math.min(totalItemCount, MAX_PAGINATED_ITEMS);
const pagination = {
pageIndex: tableState.from / tableState.size,
pageSize: tableState.size,
totalItemCount: displayedItemCount,
pageSizeOptions: [25, 50, 100],
};
const exceededResultCount = totalItemCount > MAX_PAGINATED_ITEMS;
return permissionDenied ? (
<PermissionDenied />
) : (
@ -416,16 +487,25 @@ export const RolesGridPage: FC<Props> = ({
incremental: true,
'data-test-subj': 'searchRoles',
}}
onChange={(query: Record<string, any>) => {
setFilter(query.queryText);
setVisibleRoles(getVisibleRoles(roles, query.queryText, includeReservedRoles));
}}
onChange={onSearchChange}
toolsLeft={renderToolsLeft()}
toolsRight={renderToolsRight()}
/>
<EuiSpacer size="s" />
<EuiInMemoryTable
data-test-subj="rolesTable"
{exceededResultCount && (
<>
<EuiText color="subdued" size="s" data-test-subj="rolesTableTooManyResultsLabel">
<FormattedMessage
id="xpack.security.management.roles.table.tooManyResultsLabel"
defaultMessage="Showing {limit} of {totalItemCount, plural, one {# role} other {# roles}}"
values={{ totalItemCount, limit: MAX_PAGINATED_ITEMS }}
/>
</EuiText>
<EuiSpacer size="s" />
</>
)}
<EuiBasicTable
data-test-subj={`${!isLoading ? 'rolesTable' : 'rolesTableLoading'}`}
itemId="name"
columns={getColumnConfig()}
selection={
@ -439,11 +519,9 @@ export const RolesGridPage: FC<Props> = ({
selected: selection,
}
}
pagination={{
initialPageSize: 20,
pageSizeOptions: [10, 20, 30, 50, 100],
}}
message={
onChange={onTableChange}
pagination={pagination}
noItemsMessage={
buildFlavor === 'serverless' ? (
<FormattedMessage
id="xpack.security.management.roles.noCustomRolesFound"
@ -456,13 +534,10 @@ export const RolesGridPage: FC<Props> = ({
/>
)
}
items={visibleRoles}
items={tableItems}
loading={isLoading}
sorting={{
sort: {
field: 'name',
direction: 'asc',
},
sort: tableState.sort,
}}
rowProps={{ 'data-test-subj': 'roleRow' }}
/>

View file

@ -179,7 +179,7 @@ describe('GET all roles by space id', () => {
});
if (apiResponse) {
mockCoreContext.elasticsearch.client.asCurrentUser.security.getRole.mockResponseImplementation(
mockCoreContext.elasticsearch.client.asCurrentUser.security.queryRole.mockResponseImplementation(
(() => ({ body: apiResponse() })) as any
);
}
@ -203,7 +203,7 @@ describe('GET all roles by space id', () => {
if (apiResponse) {
expect(
mockCoreContext.elasticsearch.client.asCurrentUser.security.getRole
mockCoreContext.elasticsearch.client.asCurrentUser.security.queryRole
).toHaveBeenCalled();
}
expect(mockLicensingContext.license.check).toHaveBeenCalledWith('security', 'basic');
@ -226,24 +226,29 @@ describe('GET all roles by space id', () => {
getRolesTest(`returns error if we have empty resources`, {
apiResponse: () => ({
first_role: {
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['read'],
resources: [],
total: 1,
count: 1,
roles: [
{
name: 'first_role',
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['read'],
resources: [],
},
],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
},
],
}),
asserts: {
statusCode: 500,
@ -255,29 +260,34 @@ describe('GET all roles by space id', () => {
describe('success', () => {
getRolesTest(`returns empty roles list if there is no space match`, {
apiResponse: () => ({
first_role: {
cluster: ['manage_watcher'],
indices: [
{
names: ['.kibana*'],
privileges: ['read', 'view_index_metadata'],
total: 1,
count: 1,
roles: [
{
name: 'first_role',
cluster: ['manage_watcher'],
indices: [
{
names: ['.kibana*'],
privileges: ['read', 'view_index_metadata'],
},
],
applications: [
{
application,
privileges: ['space_all', 'space_read'],
resources: ['space:marketing', 'space:sales'],
},
],
run_as: ['other_user'],
metadata: {
_reserved: true,
},
],
applications: [
{
application,
privileges: ['space_all', 'space_read'],
resources: ['space:marketing', 'space:sales'],
transient_metadata: {
enabled: true,
},
],
run_as: ['other_user'],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
},
],
}),
asserts: {
statusCode: 200,
@ -287,48 +297,54 @@ describe('GET all roles by space id', () => {
getRolesTest(`returns roles for matching space`, {
apiResponse: () => ({
first_role: {
description: 'first role description',
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['space_all', 'space_read'],
resources: ['space:marketing', 'space:sales'],
total: 2,
count: 2,
roles: [
{
name: 'first_role',
description: 'first role description',
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['space_all', 'space_read'],
resources: ['space:marketing', 'space:sales'],
},
{
application,
privileges: ['space_read'],
resources: ['space:engineering'],
},
],
run_as: [],
metadata: {
_reserved: true,
},
{
application,
privileges: ['space_read'],
resources: ['space:engineering'],
transient_metadata: {
enabled: true,
},
],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
},
second_role: {
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['space_all', 'space_read'],
resources: ['space:marketing', 'space:sales'],
{
name: 'second_role',
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['space_all', 'space_read'],
resources: ['space:marketing', 'space:sales'],
},
],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
},
],
}),
spaceId: 'engineering',
asserts: {
@ -369,43 +385,49 @@ describe('GET all roles by space id', () => {
getRolesTest(`returns roles with access to all spaces`, {
apiResponse: () => ({
first_role: {
description: 'first role description',
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['all', 'read'],
resources: ['*'],
total: 2,
count: 2,
roles: [
{
name: 'first_role',
description: 'first role description',
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['all', 'read'],
resources: ['*'],
},
],
run_as: [],
metadata: {
_reserved: true,
},
],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
},
second_role: {
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['space_all', 'space_read'],
resources: ['space:marketing', 'space:sales'],
transient_metadata: {
enabled: true,
},
],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
{
name: 'second_role',
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['space_all', 'space_read'],
resources: ['space:marketing', 'space:sales'],
},
],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
},
},
],
}),
asserts: {
statusCode: 200,
@ -440,46 +462,53 @@ describe('GET all roles by space id', () => {
getRolesTest(`filters roles with reserved only privileges`, {
apiResponse: () => ({
first_role: {
description: 'first role description',
cluster: [],
indices: [],
applications: [],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
},
second_role: {
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['space_all', 'space_read'],
resources: ['space:marketing', 'space:sales'],
total: 3,
count: 3,
roles: [
{
name: 'first_role',
description: 'first role description',
cluster: [],
indices: [],
applications: [],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
{
name: 'second_role',
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['space_all', 'space_read'],
resources: ['space:marketing', 'space:sales'],
},
],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
},
},
third_role: {
cluster: [],
indices: [],
applications: [],
run_as: [],
transient_metadata: {
enabled: true,
{
name: 'third_role',
cluster: [],
indices: [],
applications: [],
run_as: [],
transient_metadata: {
enabled: true,
},
},
},
],
}),
spaceId: 'marketing',
asserts: {
@ -517,20 +546,25 @@ describe('GET all roles by space id', () => {
getRolesTest(`replaces privileges of deprecated features by default`, {
apiResponse: () => ({
first_role: {
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['feature_alpha.read'],
resources: ['*'],
},
],
run_as: [],
metadata: { _reserved: true },
transient_metadata: { enabled: true },
},
total: 1,
count: 1,
roles: [
{
name: 'first_role',
cluster: [],
indices: [],
applications: [
{
application,
privileges: ['feature_alpha.read'],
resources: ['*'],
},
],
run_as: [],
metadata: { _reserved: true },
transient_metadata: { enabled: true },
},
],
}),
asserts: {
statusCode: 200,

View file

@ -4,6 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SecurityQueryRoleQueryRole } from '@elastic/elasticsearch/lib/api/types';
import { schema } from '@kbn/config-schema';
import type { RouteDefinitionParams } from '../..';
@ -38,10 +40,47 @@ export function defineGetAllRolesBySpaceRoutes({
const hideReservedRoles = buildFlavor === 'serverless';
const esClient = (await context.core).elasticsearch.client;
const [features, elasticsearchRoles] = await Promise.all([
const [features, queryRolesResponse] = await Promise.all([
getFeatures(),
await esClient.asCurrentUser.security.getRole(),
await esClient.asCurrentUser.security.queryRole({
query: {
bool: {
should: [
{
term: {
'applications.resources': `space:${request.params.spaceId}`,
},
},
{
term: {
'metadata._reserved': true,
},
},
{
bool: {
must_not: {
exists: {
field: 'metadata._reserved',
},
},
},
},
],
minimum_should_match: 1,
},
},
from: 0,
size: 1000,
}),
]);
const elasticsearchRoles = (queryRolesResponse.roles || [])?.reduce<
Record<string, SecurityQueryRoleQueryRole>
>((acc, role) => {
return {
...acc,
[role.name]: role,
};
}, {});
// Transform elasticsearch roles into Kibana roles and return in a list sorted by the role name.
return response.ok({
@ -53,7 +92,7 @@ export function defineGetAllRolesBySpaceRoutes({
const role = transformElasticsearchRoleToRole({
features,
// @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[]
// @ts-expect-error `remote_cluster` is not known in `Role` type
elasticsearchRole,
name: roleName,
application: authz.applicationName,

View file

@ -11,6 +11,7 @@ import { defineGetAllRolesRoutes } from './get_all';
import { defineGetAllRolesBySpaceRoutes } from './get_all_by_space';
import { defineBulkCreateOrUpdateRolesRoutes } from './post';
import { definePutRolesRoutes } from './put';
import { defineQueryRolesRoutes } from './query';
import type { RouteDefinitionParams } from '../..';
export function defineRolesRoutes(params: RouteDefinitionParams) {
@ -20,4 +21,5 @@ export function defineRolesRoutes(params: RouteDefinitionParams) {
definePutRolesRoutes(params);
defineGetAllRolesBySpaceRoutes(params);
defineBulkCreateOrUpdateRolesRoutes(params);
defineQueryRolesRoutes(params);
}

View file

@ -0,0 +1,421 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kibanaResponseFactory } from '@kbn/core/server';
import { coreMock, httpServerMock } from '@kbn/core/server/mocks';
import type { MockedVersionedRouter } from '@kbn/core-http-router-server-mocks';
import { KibanaFeature } from '@kbn/features-plugin/common';
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
import type { LicenseCheck } from '@kbn/licensing-plugin/server';
import { defineQueryRolesRoutes } from './query';
import { API_VERSIONS } from '../../../../common/constants';
import { routeDefinitionParamsMock } from '../../index.mock';
interface TestOptions {
name?: string;
licenseCheckResult?: LicenseCheck;
apiResponse?: () => unknown;
asserts: { statusCode: number; result?: Record<string, any>; calledWith?: Record<string, any> };
query?: Record<string, unknown>;
}
const application = 'kibana-.kibana';
const features: KibanaFeature[] = [
new KibanaFeature({
deprecated: { notice: 'It is deprecated, sorry.' },
id: 'alpha',
name: 'Feature Alpha',
app: [],
category: { id: 'alpha', label: 'alpha' },
privileges: {
all: {
savedObject: {
all: ['all-alpha-all-so'],
read: ['all-alpha-read-so'],
},
ui: ['all-alpha-ui'],
app: ['all-alpha-app'],
api: ['all-alpha-api'],
replacedBy: [{ feature: 'beta', privileges: ['all'] }],
},
read: {
savedObject: {
all: ['read-alpha-all-so'],
read: ['read-alpha-read-so'],
},
ui: ['read-alpha-ui'],
app: ['read-alpha-app'],
api: ['read-alpha-api'],
replacedBy: {
default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }],
minimal: [{ feature: 'beta', privileges: ['minimal_read'] }],
},
},
},
subFeatures: [
{
name: 'sub-feature-alpha',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'sub_alpha',
name: 'Sub Feature Alpha',
includeIn: 'all',
savedObject: {
all: ['sub-alpha-all-so'],
read: ['sub-alpha-read-so'],
},
ui: ['sub-alpha-ui'],
app: ['sub-alpha-app'],
api: ['sub-alpha-api'],
replacedBy: [
{ feature: 'beta', privileges: ['minimal_read'] },
{ feature: 'beta', privileges: ['sub_beta'] },
],
},
],
},
],
},
],
}),
new KibanaFeature({
id: 'beta',
name: 'Feature Beta',
app: [],
category: { id: 'beta', label: 'beta' },
privileges: {
all: {
savedObject: {
all: ['all-beta-all-so'],
read: ['all-beta-read-so'],
},
ui: ['all-beta-ui'],
app: ['all-beta-app'],
api: ['all-beta-api'],
},
read: {
savedObject: {
all: ['read-beta-all-so'],
read: ['read-beta-read-so'],
},
ui: ['read-beta-ui'],
app: ['read-beta-app'],
api: ['read-beta-api'],
},
},
subFeatures: [
{
name: 'sub-feature-beta',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'sub_beta',
name: 'Sub Feature Beta',
includeIn: 'all',
savedObject: {
all: ['sub-beta-all-so'],
read: ['sub-beta-read-so'],
},
ui: ['sub-beta-ui'],
app: ['sub-beta-app'],
api: ['sub-beta-api'],
},
],
},
],
},
],
}),
];
describe('Query roles', () => {
const queryRolesTest = (
description: string,
{ licenseCheckResult = { state: 'valid' }, apiResponse, asserts, query }: TestOptions
) => {
test(description, async () => {
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
const versionedRouterMock = mockRouteDefinitionParams.router
.versioned as MockedVersionedRouter;
mockRouteDefinitionParams.authz.applicationName = application;
mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue(features);
mockRouteDefinitionParams.subFeaturePrivilegeIterator =
featuresPluginMock.createSetup().subFeaturePrivilegeIterator;
defineQueryRolesRoutes(mockRouteDefinitionParams);
const { handler: routeHandler } = versionedRouterMock.getRoute(
'post',
'/api/security/role/_query'
).versions[API_VERSIONS.roles.public.v1];
const mockCoreContext = coreMock.createRequestHandlerContext();
const mockLicensingContext = {
license: { check: jest.fn().mockReturnValue(licenseCheckResult) },
} as any;
const mockContext = coreMock.createCustomRequestHandlerContext({
core: mockCoreContext,
licensing: mockLicensingContext,
});
if (apiResponse) {
mockCoreContext.elasticsearch.client.asCurrentUser.security.queryRole.mockResponseImplementation(
(() => ({ body: apiResponse() })) as any
);
}
const headers = { authorization: 'foo' };
const mockRequest = httpServerMock.createKibanaRequest({
method: 'post',
path: '/api/security/role/_query',
headers,
query,
});
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(asserts.statusCode);
expect(response.payload).toEqual(asserts.result);
if (apiResponse) {
expect(
mockCoreContext.elasticsearch.client.asCurrentUser.security.queryRole
).toHaveBeenCalled();
}
expect(mockLicensingContext.license.check).toHaveBeenCalledWith('security', 'basic');
});
};
describe('success', () => {
queryRolesTest('query all roles', {
apiResponse: () => ({
total: 5,
count: 2,
roles: [
{
name: 'apm_system',
cluster: ['monitor', 'cluster:admin/xpack/monitoring/bulk'],
indices: [
{
names: ['.monitoring-beats-*'],
privileges: ['create_index', 'create_doc'],
allow_restricted_indices: false,
},
],
applications: [],
run_as: [],
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
_sort: ['apm_system'],
},
{
name: 'user_role',
cluster: [],
indices: [
{
names: ['.management-beats'],
privileges: ['all'],
allow_restricted_indices: false,
},
],
applications: [],
run_as: [],
metadata: {},
transient_metadata: {
enabled: true,
},
_sort: ['user_role'],
},
],
}),
query: {
from: 0,
size: 25,
},
asserts: {
statusCode: 200,
result: {
roles: [
{
name: 'apm_system',
metadata: {
_reserved: true,
},
transient_metadata: {
enabled: true,
},
elasticsearch: {
cluster: ['monitor', 'cluster:admin/xpack/monitoring/bulk'],
indices: [
{
names: ['.monitoring-beats-*'],
privileges: ['create_index', 'create_doc'],
allow_restricted_indices: false,
},
],
run_as: [],
},
kibana: [],
_transform_error: [],
_unrecognized_applications: [],
},
{
name: 'user_role',
metadata: {},
transient_metadata: {
enabled: true,
},
elasticsearch: {
cluster: [],
indices: [
{
names: ['.management-beats'],
privileges: ['all'],
allow_restricted_indices: false,
},
],
run_as: [],
},
kibana: [],
_transform_error: [],
_unrecognized_applications: [],
},
],
count: 2,
total: 5,
},
calledWith: {
from: 0,
size: 25,
sort: undefined,
query: {
bool: {
minimum_should_match: 1,
must: [],
must_not: [],
should: [
{ term: { 'metadata._reserved': true } },
{
bool: {
must_not: {
exists: {
field: 'metadata._reserved',
},
},
},
},
],
},
},
},
},
});
queryRolesTest('hide reserved roles', {
apiResponse: () => ({
total: 1,
count: 1,
roles: [
{
name: 'user_role',
cluster: [],
indices: [
{
names: ['.management-beats'],
privileges: ['all'],
allow_restricted_indices: false,
},
],
applications: [],
run_as: [],
metadata: {},
transient_metadata: {
enabled: true,
},
_sort: ['user_role'],
},
],
}),
query: {
from: 0,
size: 25,
},
asserts: {
statusCode: 200,
result: {
roles: [
{
name: 'user_role',
metadata: {},
transient_metadata: {
enabled: true,
},
elasticsearch: {
cluster: [],
indices: [
{
names: ['.management-beats'],
privileges: ['all'],
allow_restricted_indices: false,
},
],
run_as: [],
},
kibana: [],
_transform_error: [],
_unrecognized_applications: [],
},
],
count: 1,
total: 1,
},
calledWith: {
query: {
bool: {
must: [],
should: [
{
term: {
'metadata._reserved': false,
},
},
{
bool: {
must_not: {
exists: {
field: 'metadata._reserved',
},
},
},
},
],
must_not: [],
minimum_should_match: 1,
},
},
from: 0,
size: 2,
sort: [
{
name: {
order: 'asc',
},
},
],
},
},
});
});
});

View file

@ -0,0 +1,155 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import type { QueryRolesResult } from '@kbn/security-plugin-types-common';
import type { RouteDefinitionParams } from '../..';
import { API_VERSIONS } from '../../../../common/constants';
import { transformElasticsearchRoleToRole } from '../../../authorization';
import { wrapIntoCustomErrorResponse } from '../../../errors';
import { createLicensedRouteHandler } from '../../licensed_route_handler';
interface QueryClause {
[key: string]: any;
}
export function defineQueryRolesRoutes({
router,
authz,
getFeatures,
logger,
buildFlavor,
}: RouteDefinitionParams) {
router.versioned
.post({
path: '/api/security/role/_query',
access: 'public',
summary: `Query roles`,
options: {
tags: ['oas-tags:roles'],
},
})
.addVersion(
{
version: API_VERSIONS.roles.public.v1,
security: {
authz: {
enabled: false,
reason: `This route delegates authorization to Core's scoped ES cluster client`,
},
},
validate: {
request: {
body: schema.object({
query: schema.maybe(schema.string()),
from: schema.maybe(schema.number()),
size: schema.maybe(schema.number()),
sort: schema.maybe(
schema.object({
field: schema.string(),
direction: schema.oneOf([schema.literal('asc'), schema.literal('desc')]),
})
),
filters: schema.maybe(
schema.object({
showReservedRoles: schema.maybe(schema.boolean({ defaultValue: true })),
})
),
}),
},
response: {
200: {
description: 'Indicates a successful call.',
},
},
},
},
createLicensedRouteHandler(async (context, request, response) => {
try {
const esClient = (await context.core).elasticsearch.client;
const features = await getFeatures();
const { query, size, from, sort, filters } = request.body;
let showReservedRoles = filters?.showReservedRoles;
if (buildFlavor === 'serverless') {
showReservedRoles = false;
}
const queryPayload: {
bool: {
must: QueryClause[];
should: QueryClause[];
must_not: QueryClause[];
minimum_should_match?: number;
};
} = { bool: { must: [], should: [], must_not: [] } };
const nonReservedRolesQuery = [
{
bool: {
must_not: {
exists: {
field: 'metadata._reserved',
},
},
},
},
];
queryPayload.bool.should.push(...nonReservedRolesQuery);
queryPayload.bool.minimum_should_match = 1;
if (query) {
queryPayload.bool.must.push({
wildcard: {
name: {
value: `*${query}*`,
case_insensitive: true,
},
},
});
}
if (showReservedRoles) {
queryPayload.bool.should.push({ term: { 'metadata._reserved': true } });
}
const transformedSort = sort && [{ [sort.field]: { order: sort.direction } }];
const queryRoles = await esClient.asCurrentUser.security.queryRole({
query: queryPayload,
from,
size,
sort: transformedSort,
});
const transformedRoles = (queryRoles.roles || []).map((role) =>
transformElasticsearchRoleToRole({
features,
// @ts-expect-error `remote_cluster` is not known in `Role` type
elasticsearchRole: role,
name: role.name,
application: authz.applicationName,
logger,
})
);
return response.ok<QueryRolesResult>({
body: {
roles: transformedRoles,
count: queryRoles.count,
total: queryRoles.total,
},
});
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -13,5 +13,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
return {
...baseIntegrationTestsConfig.getAll(),
testFiles: [require.resolve('.')],
esTestCluster: {
...baseIntegrationTestsConfig.get('esTestCluster'),
},
};
}

View file

@ -519,5 +519,118 @@ export default function ({ getService }: FtrProviderContext) {
expect(roleToUpdateWithDlsFls).to.eql({});
});
});
describe('Query Role', () => {
it('should query roles by name', async () => {
await es.security.putRole({
name: 'role_to_query',
body: {
cluster: ['manage'],
indices: [
{
names: ['logstash-*'],
privileges: ['read', 'view_index_metadata'],
allow_restricted_indices: false,
},
],
applications: [
{
application: 'kibana-.kibana',
privileges: ['read'],
resources: ['*'],
},
{
application: 'kibana-.kibana',
privileges: ['feature_dashboard.read', 'feature_discover.all', 'feature_ml.all'],
resources: ['space:marketing', 'space:sales'],
},
{
application: 'logstash-default',
privileges: ['logstash-privilege'],
resources: ['*'],
},
],
run_as: ['watcher_user'],
metadata: {
foo: 'test-metadata',
},
transient_metadata: {
enabled: true,
},
},
});
await supertest
.post('/api/security/role/_query')
.send({
from: 0,
size: 25,
query: 'role_to_query',
})
.set('kbn-xsrf', 'xxx')
.expect(200, {
total: 1,
count: 1,
roles: [
{
name: 'role_to_query',
metadata: {
foo: 'test-metadata',
},
transient_metadata: { enabled: true },
elasticsearch: {
cluster: ['manage'],
indices: [
{
names: ['logstash-*'],
privileges: ['read', 'view_index_metadata'],
allow_restricted_indices: false,
},
],
run_as: ['watcher_user'],
},
kibana: [
{
base: ['read'],
feature: {},
spaces: ['*'],
},
{
base: [],
feature: {
dashboard: ['read'],
discover: ['all'],
ml: ['all'],
},
spaces: ['marketing', 'sales'],
},
],
_transform_error: [],
_unrecognized_applications: ['logstash-default'],
},
],
});
});
it('should hide reserved roles when filtered', async () => {
const response = await supertest
.post('/api/security/role/_query')
.send({
from: 0,
size: 100,
filters: {
showReservedRoles: false,
},
})
.set('kbn-xsrf', 'xxx')
.expect(200);
const filteredResults = response.body.roles.filter(
(role: any) => role.metadata._reserved === true
);
expect(filteredResults.length).to.eql(0);
});
});
});
}

View file

@ -13,5 +13,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
return {
...functionalConfig.getAll(),
testFiles: [require.resolve('.')],
esTestCluster: {
...functionalConfig.get('esTestCluster'),
},
};
}

View file

@ -13,5 +13,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
return {
...functionalConfig.getAll(),
testFiles: [require.resolve('.')],
esTestCluster: {
...functionalConfig.get('esTestCluster'),
},
};
}

View file

@ -458,8 +458,12 @@ export class SecurityPageObject extends FtrService {
async getElasticsearchRoles() {
const roles = [];
await this.testSubjects.exists('rolesTable');
await this.testSubjects.click('tablePaginationPopoverButton');
await this.testSubjects.click('tablePagination-100-rows');
await this.testSubjects.exists('rolesTableLoading');
await this.testSubjects.exists('rolesTable');
for (const role of await this.testSubjects.findAll('roleRow')) {
const [rolename, reserved, deprecated] = await Promise.all([
role.findByTestSubject('roleRowName').then((el) => el.getVisibleText()),