diff --git a/oas_docs/bundle.json b/oas_docs/bundle.json index 8ed34159e403b..eafa8288be1fa 100644 --- a/oas_docs/bundle.json +++ b/oas_docs/bundle.json @@ -39185,6 +39185,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", diff --git a/oas_docs/bundle.serverless.json b/oas_docs/bundle.serverless.json index dc9deb7397601..f646c16f18d31 100644 --- a/oas_docs/bundle.serverless.json +++ b/oas_docs/bundle.serverless.json @@ -39185,6 +39185,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", diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 40ed1874797c1..6042d45db0715 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -34431,6 +34431,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 diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index c398c20361e64..2a2e10b704020 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -36992,6 +36992,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 diff --git a/x-pack/platform/packages/shared/security/plugin_types_common/index.ts b/x-pack/platform/packages/shared/security/plugin_types_common/index.ts index 840e32a77b9c1..7e449e997df87 100644 --- a/x-pack/platform/packages/shared/security/plugin_types_common/index.ts +++ b/x-pack/platform/packages/shared/security/plugin_types_common/index.ts @@ -12,6 +12,8 @@ export type { AuthenticationProvider, } from './src/authentication'; export type { + QueryRolesRole, + QueryRolesResult, Role, RoleIndexPrivilege, RoleKibanaPrivilege, diff --git a/x-pack/platform/packages/shared/security/plugin_types_common/src/authorization/index.ts b/x-pack/platform/packages/shared/security/plugin_types_common/src/authorization/index.ts index 2a4462960b376..698667d631c2b 100644 --- a/x-pack/platform/packages/shared/security/plugin_types_common/src/authorization/index.ts +++ b/x-pack/platform/packages/shared/security/plugin_types_common/src/authorization/index.ts @@ -8,6 +8,8 @@ export type { FeaturesPrivileges } from './features_privileges'; export type { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from './raw_kibana_privileges'; export type { + QueryRolesRole, + QueryRolesResult, Role, RoleKibanaPrivilege, RoleIndexPrivilege, diff --git a/x-pack/platform/packages/shared/security/plugin_types_common/src/authorization/role.ts b/x-pack/platform/packages/shared/security/plugin_types_common/src/authorization/role.ts index 3a20b64d4d06c..79fe064752e0d 100644 --- a/x-pack/platform/packages/shared/security/plugin_types_common/src/authorization/role.ts +++ b/x-pack/platform/packages/shared/security/plugin_types_common/src/authorization/role.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; + import type { FeaturesPrivileges } from './features_privileges'; export interface RoleIndexPrivilege { @@ -53,3 +55,11 @@ export interface Role { _transform_error?: string[]; _unrecognized_applications?: string[]; } + +export type QueryRolesRole = estypes.SecurityQueryRoleQueryRole; + +export interface QueryRolesResult { + roles: Role[]; + count: number; + total: number; +} diff --git a/x-pack/platform/plugins/shared/security/public/management/roles/roles_api_client.mock.ts b/x-pack/platform/plugins/shared/security/public/management/roles/roles_api_client.mock.ts index 5f868fda093a4..015f43af4452c 100644 --- a/x-pack/platform/plugins/shared/security/public/management/roles/roles_api_client.mock.ts +++ b/x-pack/platform/plugins/shared/security/public/management/roles/roles_api_client.mock.ts @@ -12,5 +12,6 @@ export const rolesAPIClientMock = { deleteRole: jest.fn(), saveRole: jest.fn(), bulkUpdateRoles: jest.fn(), + queryRoles: jest.fn(), }), }; diff --git a/x-pack/platform/plugins/shared/security/public/management/roles/roles_api_client.ts b/x-pack/platform/plugins/shared/security/public/management/roles/roles_api_client.ts index e307bd8b56877..bbef97370df17 100644 --- a/x-pack/platform/plugins/shared/security/public/management/roles/roles_api_client.ts +++ b/x-pack/platform/plugins/shared/security/public/management/roles/roles_api_client.ts @@ -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['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(`/api/security/role/_query`, { + version, + body: JSON.stringify(params || {}), + }); + }; + public getRole = async (roleName: string) => { return await this.http.get(`/api/security/role/${encodeURIComponent(roleName)}`, { version, diff --git a/x-pack/platform/plugins/shared/security/public/management/roles/roles_grid/roles_grid_page.test.tsx b/x-pack/platform/plugins/shared/security/public/management/roles/roles_grid/roles_grid_page.test.tsx index 1c965551a63dc..f34bd61ab75df 100644 --- a/x-pack/platform/plugins/shared/security/public/management/roles/roles_grid/roles_grid_page.test.tsx +++ b/x-pack/platform/plugins/shared/security/public/management/roles/roles_grid/roles_grid_page.test.tsx @@ -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('', () => { 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('', () => { }); it('renders permission denied if required', async () => { - apiClientMock.getRoles.mockRejectedValue(mock403()); + apiClientMock.queryRoles.mockRejectedValue(mock403()); const wrapper = mountWithIntl( ', () => { ); }); - it('hides reserved roles when instructed to', async () => { - const wrapper = mountWithIntl( - - ); - 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( - - ); - 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( ['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 = ({ @@ -77,25 +100,28 @@ export const RolesGridPage: FC = ({ cloudOrgUrl, ...startServices }) => { - const [roles, setRoles] = useState([]); - const [visibleRoles, setVisibleRoles] = useState([]); + const [rolesResponse, setRolesResponse] = useState({} as QueryRolesResult); + const [selection, setSelection] = useState([]); - const [filter, setFilter] = useState(''); const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); const [permissionDenied, setPermissionDenied] = useState(false); - const [includeReservedRoles, setIncludeReservedRoles] = useState(true); const [isLoading, setIsLoading] = useState(false); - useEffect(() => { - loadRoles(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + const [tableState, setTableState] = useState(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 = ({ } }; + 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 = ({ const handleDelete = () => { setSelection([]); setShowDeleteConfirmation(false); - loadRoles(); + loadRoles(tableState); }; const deleteOneRole = (roleToDelete: Role) => { @@ -203,13 +239,35 @@ export const RolesGridPage: FC = ({ defaultMessage="Show reserved roles" /> } - checked={includeReservedRoles} + checked={tableState.filters.showReservedRoles ?? true} onChange={onIncludeReservedRolesChange} /> ); } }; + const onTableChange = ({ page, sort }: CriteriaWithPagination) => { + 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> => { const config: Array> = [ { @@ -234,7 +292,7 @@ export const RolesGridPage: FC = ({ name: i18n.translate('xpack.security.management.roles.descriptionColumnName', { defaultMessage: 'Role Description', }), - sortable: true, + sortable: false, truncateText: { lines: 3 }, render: (description: string, record: Role) => ( @@ -251,7 +309,7 @@ export const RolesGridPage: FC = ({ 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 = ({ 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 ? ( ) : ( @@ -416,16 +487,25 @@ export const RolesGridPage: FC = ({ incremental: true, 'data-test-subj': 'searchRoles', }} - onChange={(query: Record) => { - setFilter(query.queryText); - setVisibleRoles(getVisibleRoles(roles, query.queryText, includeReservedRoles)); - }} + onChange={onSearchChange} toolsLeft={renderToolsLeft()} toolsRight={renderToolsRight()} /> - + + + + + + )} + = ({ selected: selection, } } - pagination={{ - initialPageSize: 20, - pageSizeOptions: [10, 20, 30, 50, 100], - }} - message={ + onChange={onTableChange} + pagination={pagination} + noItemsMessage={ buildFlavor === 'serverless' ? ( = ({ /> ) } - items={visibleRoles} + items={tableItems} loading={isLoading} sorting={{ - sort: { - field: 'name', - direction: 'asc', - }, + sort: tableState.sort, }} rowProps={{ 'data-test-subj': 'roleRow' }} /> diff --git a/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/get_all_by_space.test.ts b/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/get_all_by_space.test.ts index 956ced4309304..46b5199bef39c 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/get_all_by_space.test.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/get_all_by_space.test.ts @@ -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, diff --git a/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/get_all_by_space.ts b/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/get_all_by_space.ts index a441ba15164c1..53419bfe3b07a 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/get_all_by_space.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/get_all_by_space.ts @@ -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 + >((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, diff --git a/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/index.ts b/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/index.ts index d5af481ca3c11..3207be94b8afe 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/index.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/index.ts @@ -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); } diff --git a/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/query.test.ts b/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/query.test.ts new file mode 100644 index 0000000000000..b63aedf147804 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/query.test.ts @@ -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; calledWith?: Record }; + query?: Record; +} + +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', + }, + }, + ], + }, + }, + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/query.ts b/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/query.ts new file mode 100644 index 0000000000000..684e69634735b --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/routes/authorization/roles/query.ts @@ -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({ + body: { + roles: transformedRoles, + count: queryRoles.count, + total: queryRoles.total, + }, + }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/test/api_integration/apis/security/config.ts b/x-pack/test/api_integration/apis/security/config.ts index 5f335f116fefe..75237366ab10c 100644 --- a/x-pack/test/api_integration/apis/security/config.ts +++ b/x-pack/test/api_integration/apis/security/config.ts @@ -13,5 +13,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...baseIntegrationTestsConfig.getAll(), testFiles: [require.resolve('.')], + esTestCluster: { + ...baseIntegrationTestsConfig.get('esTestCluster'), + // TODO: remove this once ES has built-in roles enabled by default + esJavaOpts: '-Des.queryable_built_in_roles_enabled=true', + }, }; } diff --git a/x-pack/test/api_integration/apis/security/roles.ts b/x-pack/test/api_integration/apis/security/roles.ts index f6cf615d0f71b..e49d19f15afd3 100644 --- a/x-pack/test/api_integration/apis/security/roles.ts +++ b/x-pack/test/api_integration/apis/security/roles.ts @@ -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); + }); + }); }); } diff --git a/x-pack/test/functional/apps/security/config.ts b/x-pack/test/functional/apps/security/config.ts index d0d07ff200281..1f851048ce923 100644 --- a/x-pack/test/functional/apps/security/config.ts +++ b/x-pack/test/functional/apps/security/config.ts @@ -13,5 +13,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), testFiles: [require.resolve('.')], + esTestCluster: { + ...functionalConfig.get('esTestCluster'), + // TODO: remove this once ES has built-in roles enabled by default + esJavaOpts: '-Des.queryable_built_in_roles_enabled=true', + }, }; } diff --git a/x-pack/test/functional/apps/spaces/config.ts b/x-pack/test/functional/apps/spaces/config.ts index d0d07ff200281..1f851048ce923 100644 --- a/x-pack/test/functional/apps/spaces/config.ts +++ b/x-pack/test/functional/apps/spaces/config.ts @@ -13,5 +13,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), testFiles: [require.resolve('.')], + esTestCluster: { + ...functionalConfig.get('esTestCluster'), + // TODO: remove this once ES has built-in roles enabled by default + esJavaOpts: '-Des.queryable_built_in_roles_enabled=true', + }, }; } diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index c645c4eb8079d..a2fdfc157ce69 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -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()),