Replace usage of Get API keys endpoint with Query API keys endpoint for API keys grid page (#168970)

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

## Release notes

Enhanced API key management to manage larger number of API keys by
adding server side filtering, pagination and querying.

## Summary
- Replaced the usage of Get API keys API with Query API keys API
- Added server side pagination, filtering and aggregations 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 and filters now work independently. Toggling through
filters will not update the search bar. Instead they are sent as filters
to the internal endpoint to then create a custom DSL.

### Screen recordings

4dd5ab09-fc3e-44bf-9642-1e7fd0334041

## Technical notes
- Client side EuiInMemory table has been replaced by EuiSearchBar,
EuiBasicTable and Filters
- Search Bar construct Elastic DSL which is then passed to the `query`
endpoint to query API keys
- Filters are sent as extra objects to internal endpoint to be used to
extend any incoming query DSL
- One new Kibana endpoints added
- `api_keys/_query` - Returns server side results for any given query
DSL
- Removed internal GET `api_keys` endpoint as it was no longer being
used.
- Extra logic to handle previously UI only filters `expired:true/false`
and `type:managed.
- Parse the query to construct the correct DSL if any or both of the
above queries are present.
- Filter invalidated keys in the internal endpoint handler. 

## Testing
### Automated tests

Added to existing unit tests for UI functionality in
`x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx`,

Added new api integration tests for API key aggregations and querying
in: `x-pack/test/api_integration/apis/security/query_api_keys.ts`

Which can be run by either
```node scripts/functional_tests_server.js --config x-pack/test/api_integration/config_security_basic.ts```

or 

```node scripts/functional_tests_server.js --config
x-pack/test/api_integration/config_security_trial.ts```

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Cee Chen <constance.chen@elastic.co>
This commit is contained in:
Sid 2024-05-23 14:13:22 +02:00 committed by GitHub
parent 3a0aa1a65b
commit a834d76f22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1624 additions and 879 deletions

View file

@ -41,7 +41,7 @@ export interface CrossClusterApiKey extends BaseApiKey {
*
* TODO: Remove this type when `@elastic/elasticsearch` has been updated.
*/
interface BaseApiKey extends estypes.SecurityApiKey {
export interface BaseApiKey extends estypes.SecurityApiKey {
username: Required<estypes.SecurityApiKey>['username'];
realm: Required<estypes.SecurityApiKey>['realm'];
creation: Required<estypes.SecurityApiKey>['creation'];
@ -77,3 +77,41 @@ export interface ApiKeyToInvalidate {
id: string;
name: string;
}
export interface ApiKeyAggregations {
usernames?: estypes.AggregationsStringTermsAggregate;
types?: estypes.AggregationsStringTermsAggregate;
expired?: estypes.AggregationsFilterAggregateKeys;
managed?: {
buckets: {
metadataBased: estypes.AggregationsFilterAggregateKeys;
namePrefixBased: estypes.AggregationsFilterAggregateKeys;
};
};
}
/**
* Response of Kibana Query API keys endpoint.
*/
export type QueryApiKeyResult = SuccessQueryApiKeyResult | ErrorQueryApiKeyResult;
interface SuccessQueryApiKeyResult extends BaseQueryApiKeyResult {
apiKeys: ApiKey[];
count: number;
total: number;
queryError: never;
}
interface ErrorQueryApiKeyResult extends BaseQueryApiKeyResult {
queryError: { name: string; message: string };
apiKeys: never;
total: never;
}
interface BaseQueryApiKeyResult {
canManageCrossClusterApiKeys: boolean;
canManageApiKeys: boolean;
canManageOwnApiKeys: boolean;
aggregationTotal: number;
aggregations: Record<string, estypes.SecurityQueryApiKeysAPIKeyAggregate> | undefined;
}

View file

@ -8,10 +8,13 @@
export type {
ApiKey,
RestApiKey,
BaseApiKey,
CrossClusterApiKey,
ApiKeyToInvalidate,
ApiKeyRoleDescriptors,
CrossClusterApiKeyAccess,
ApiKeyAggregations,
QueryApiKeyResult,
} from './api_key';
export type { EditUser, GetUserDisplayNameParams } from './user';
export type { GetUserProfileResponse } from './user_profile';

View file

@ -19,7 +19,7 @@ import {
EuiText,
} from '@elastic/eui';
import type { FunctionComponent, ReactElement } from 'react';
import React from 'react';
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
@ -83,10 +83,15 @@ export interface SelectableTokenFieldProps extends Omit<EuiFieldTextProps, 'valu
export const SelectableTokenField: FunctionComponent<SelectableTokenFieldProps> = (props) => {
const { options, ...rest } = props;
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [selectedOption, setSelectedOption] = React.useState<SelectableTokenFieldOption>(
options[0]
);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [selectedOption, setSelectedOption] = useState<SelectableTokenFieldOption>(options[0]);
useEffect(() => {
if (options.length > 0) {
setSelectedOption(options[0]);
}
}, [options]);
const selectedIndex = options.findIndex((c) => c.key === selectedOption.key);
const closePopover = () => setIsPopoverOpen(false);

View file

@ -11,9 +11,9 @@ import type { APIKeysAPIClient } from './api_keys_api_client';
export const apiKeysAPIClientMock = {
create: (): jest.Mocked<PublicMethodsOf<APIKeysAPIClient>> => ({
getApiKeys: jest.fn(),
invalidateApiKeys: jest.fn(),
createApiKey: jest.fn(),
updateApiKey: jest.fn(),
queryApiKeys: jest.fn(),
}),
};

View file

@ -8,22 +8,9 @@
import { httpServiceMock } from '@kbn/core/public/mocks';
import { APIKeysAPIClient } from './api_keys_api_client';
import type { QueryApiKeyParams } from './api_keys_api_client';
describe('APIKeysAPIClient', () => {
it('getApiKeys() queries correct endpoint', async () => {
const httpMock = httpServiceMock.createStartContract();
const mockResponse = Symbol('mockResponse');
httpMock.get.mockResolvedValue(mockResponse);
const apiClient = new APIKeysAPIClient(httpMock);
await expect(apiClient.getApiKeys()).resolves.toBe(mockResponse);
expect(httpMock.get).toHaveBeenCalledTimes(1);
expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key');
httpMock.get.mockClear();
});
it('invalidateApiKeys() queries correct endpoint', async () => {
const httpMock = httpServiceMock.createStartContract();
@ -88,4 +75,25 @@ describe('APIKeysAPIClient', () => {
body: JSON.stringify(mockApiKeyUpdate),
});
});
it('queryApiKeys() queries correct endpoint', async () => {
const httpMock = httpServiceMock.createStartContract();
const mockResponse = Symbol('mockResponse');
httpMock.post.mockResolvedValue(mockResponse);
const apiClient = new APIKeysAPIClient(httpMock);
const mockQueryParams = {
query: {},
from: 0,
size: 10,
sort: { field: 'creation', direction: 'asc' },
} as QueryApiKeyParams;
await expect(apiClient.queryApiKeys(mockQueryParams)).resolves.toBe(mockResponse);
expect(httpMock.post).toHaveBeenCalledTimes(1);
expect(httpMock.post).toHaveBeenCalledWith('/internal/security/api_key/_query', {
body: JSON.stringify(mockQueryParams),
});
});
});

View file

@ -5,15 +5,14 @@
* 2.0.
*/
import type { QueryContainer } from '@elastic/eui/src/components/search_bar/query/ast_to_es_query_dsl';
import type { HttpStart } from '@kbn/core/public';
import type { CreateAPIKeyParams, CreateAPIKeyResult } from '@kbn/security-plugin-types-server';
import type { ApiKeyToInvalidate } from '../../../common/model';
import type {
GetAPIKeysResult,
UpdateAPIKeyParams,
UpdateAPIKeyResult,
} from '../../../server/routes/api_keys';
import type { QueryFilters } from './api_keys_grid/api_keys_table';
import type { ApiKeyToInvalidate, QueryApiKeyResult } from '../../../common/model';
import type { UpdateAPIKeyParams, UpdateAPIKeyResult } from '../../../server/routes/api_keys';
export type { CreateAPIKeyParams, CreateAPIKeyResult, UpdateAPIKeyParams, UpdateAPIKeyResult };
@ -22,13 +21,41 @@ export interface InvalidateApiKeysResponse {
errors: any[];
}
export interface QueryApiKeySortOptions {
field:
| 'id'
| 'type'
| 'name'
| 'username'
| 'realm'
| 'creation'
| 'metadata'
| 'role_descriptors'
| 'expiration'
| 'invalidated'
| 'limited_by'
| '_sort'
| 'expired';
direction: 'asc' | 'desc';
}
export interface QueryApiKeyParams {
query: QueryContainer;
from: number;
size: number;
sort: QueryApiKeySortOptions;
filters: QueryFilters;
}
const apiKeysUrl = '/internal/security/api_key';
export class APIKeysAPIClient {
constructor(private readonly http: HttpStart) {}
public async getApiKeys() {
return await this.http.get<GetAPIKeysResult>(apiKeysUrl);
public async queryApiKeys(params?: QueryApiKeyParams) {
return await this.http.post<QueryApiKeyResult>(`${apiKeysUrl}/_query`, {
body: JSON.stringify(params || {}),
});
}
public async invalidateApiKeys(apiKeys: ApiKeyToInvalidate[], isAdmin = false) {

View file

@ -41,8 +41,8 @@ import { FormattedDate, FormattedMessage } from '@kbn/i18n-react';
import { useDarkMode, useKibana } from '@kbn/kibana-react-plugin/public';
import type { KibanaServerError } from '@kbn/kibana-utils-plugin/public';
import type { CategorizedApiKey } from './api_keys_grid_page';
import { ApiKeyBadge, ApiKeyStatus, TimeToolTip } from './api_keys_grid_page';
import type { CategorizedApiKey } from './api_keys_table';
import { ApiKeyBadge, ApiKeyStatus, TimeToolTip } from './api_keys_table';
import type { ApiKeyRoleDescriptors } from '../../../../common/model';
import { DocLink } from '../../../components/doc_link';
import { FormField } from '../../../components/form_field';

View file

@ -76,17 +76,22 @@ export const ApiKeysEmptyPrompt: FC<PropsWithChildren<ApiKeysEmptyPromptProps>>
throw error;
};
const promptHeading = doesErrorIndicateBadQuery(error) ? (
<FormattedMessage
id="xpack.security.management.apiKeysEmptyPrompt.badQueryErrorMessage"
defaultMessage="Could not load API keys as the query is incorrect."
/>
) : (
<FormattedMessage
id="xpack.security.management.apiKeysEmptyPrompt.errorMessage"
defaultMessage="Could not load API keys."
/>
);
return (
<KibanaPageTemplate.EmptyPrompt
iconType="warning"
body={
<p>
<FormattedMessage
id="xpack.security.management.apiKeysEmptyPrompt.errorMessage"
defaultMessage="Could not load API keys."
/>
</p>
}
body={<p>{promptHeading}</p>}
actions={
<>
{children}
@ -177,3 +182,12 @@ function doesErrorIndicateAPIKeysAreDisabled(error: Record<string, any>) {
function doesErrorIndicateUserHasNoPermissionsToManageAPIKeys(error: Record<string, any>) {
return error.body?.statusCode === 403;
}
export function doesErrorIndicateBadQuery(error: Record<string, any>) {
const message = error?.message || '';
const errorString = error?.name || '';
return (
errorString.indexOf('ResponseError') > -1 || message.indexOf('illegal_argument_exception') > -1
);
}

View file

@ -42,7 +42,7 @@ describe('APIKeysGridPage', () => {
coreStart.http.post.mockClear();
authc.getCurrentUser.mockClear();
coreStart.http.get.mockResolvedValue({
coreStart.http.post.mockResolvedValue({
apiKeys: [
{
type: 'rest',
@ -72,6 +72,43 @@ describe('APIKeysGridPage', () => {
canManageCrossClusterApiKeys: true,
canManageApiKeys: true,
canManageOwnApiKeys: true,
total: 2,
aggregationTotal: 2,
aggregations: {
usernames: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'elastic',
doc_count: 4256,
},
],
},
types: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'rest',
doc_count: 2,
},
],
},
expired: {
doc_count: 0,
},
managed: {
buckets: {
metadataBased: {
doc_count: 0,
},
namePrefixBased: {
doc_count: 0,
},
},
},
},
});
authc.getCurrentUser.mockResolvedValue(
@ -121,7 +158,7 @@ describe('APIKeysGridPage', () => {
it('displays callout when API keys are disabled', async () => {
const history = createMemoryHistory({ initialEntries: ['/'] });
coreStart.http.get.mockRejectedValueOnce({
coreStart.http.post.mockRejectedValueOnce({
body: { message: 'disabled.feature="api_keys"' },
});
@ -145,7 +182,7 @@ describe('APIKeysGridPage', () => {
it('displays error when user does not have required permissions', async () => {
const history = createMemoryHistory({ initialEntries: ['/'] });
coreStart.http.get.mockRejectedValueOnce({
coreStart.http.post.mockRejectedValueOnce({
body: { statusCode: 403, message: 'forbidden' },
});
@ -167,7 +204,7 @@ describe('APIKeysGridPage', () => {
});
it('displays error when fetching API keys fails', async () => {
coreStart.http.get.mockRejectedValueOnce({
coreStart.http.post.mockRejectedValueOnce({
body: {
error: 'Internal Server Error',
message: 'Internal Server Error',
@ -195,11 +232,14 @@ describe('APIKeysGridPage', () => {
describe('Read Only View', () => {
beforeEach(() => {
coreStart.http.get.mockResolvedValue({
coreStart.http.post.mockResolvedValue({
apiKeys: [],
canManageCrossClusterApiKeys: false,
canManageApiKeys: false,
canManageOwnApiKeys: false,
total: 0,
aggregations: {},
aggregationTotal: 0,
});
});

View file

@ -5,24 +5,10 @@
* 2.0.
*/
import type { EuiBasicTableColumn, Query, SearchFilterConfig } from '@elastic/eui';
import {
EuiBadge,
EuiButton,
EuiCallOut,
EuiFilterButton,
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiInMemoryTable,
EuiLink,
EuiSpacer,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import moment from 'moment-timezone';
import type { FunctionComponent, PropsWithChildren } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import type { Criteria, EuiSearchBarOnChangeArgs, Query } from '@elastic/eui';
import { EuiButton, EuiCallOut, EuiSearchBar, EuiSpacer } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import useAsyncFn from 'react-use/lib/useAsyncFn';
@ -33,34 +19,104 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { reactRouterNavigate, useKibana } from '@kbn/kibana-react-plugin/public';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { Route } from '@kbn/shared-ux-router';
import { UserAvatar, UserProfilesPopover } from '@kbn/user-profile-components';
import { ApiKeyFlyout } from './api_key_flyout';
import { ApiKeysEmptyPrompt } from './api_keys_empty_prompt';
import { ApiKeysTable, MAX_PAGINATED_ITEMS } from './api_keys_table';
import type { CategorizedApiKey, QueryFilters } from './api_keys_table';
import { InvalidateProvider } from './invalidate_provider';
import type { ApiKey, AuthenticatedUser, RestApiKey } from '../../../../common';
import { Breadcrumb } from '../../../components/breadcrumb';
import { SelectableTokenField } from '../../../components/token_field';
import { useCapabilities } from '../../../components/use_capabilities';
import { useAuthentication } from '../../../components/use_current_user';
import type { CreateAPIKeyResult } from '../api_keys_api_client';
import type { CreateAPIKeyResult, QueryApiKeySortOptions } from '../api_keys_api_client';
import { APIKeysAPIClient } from '../api_keys_api_client';
interface ApiKeysTableState {
query: Query;
from: number;
size: number;
sort: QueryApiKeySortOptions;
filters: QueryFilters;
}
const DEFAULT_TABLE_STATE = {
query: EuiSearchBar.Query.MATCH_ALL,
sort: {
field: 'creation' as const,
direction: 'desc' as const,
},
from: 0,
size: 25,
filters: {},
};
export const APIKeysGridPage: FunctionComponent = () => {
const { services } = useKibana<CoreStart>();
const history = useHistory();
const authc = useAuthentication();
const [state, getApiKeys] = useAsyncFn(
() => Promise.all([new APIKeysAPIClient(services.http).getApiKeys(), authc.getCurrentUser()]),
[services.http]
);
const [createdApiKey, setCreatedApiKey] = useState<CreateAPIKeyResult>();
const [openedApiKey, setOpenedApiKey] = useState<CategorizedApiKey>();
const readOnly = !useCapabilities('api_keys').save;
const [tableState, setTableState] = useState<ApiKeysTableState>(DEFAULT_TABLE_STATE);
const [state, queryApiKeysAndAggregations] = useAsyncFn((tableStateArgs: ApiKeysTableState) => {
const queryContainer = EuiSearchBar.Query.toESQuery(tableStateArgs.query);
const requestBody = {
...tableStateArgs,
query: queryContainer,
};
return Promise.all([
new APIKeysAPIClient(services.http).queryApiKeys(requestBody),
authc.getCurrentUser(),
]);
}, []);
const resetQueryOnError = () => {
setTableState(DEFAULT_TABLE_STATE);
queryApiKeysAndAggregations(DEFAULT_TABLE_STATE);
};
const onTableChange = ({ page, sort }: Criteria<CategorizedApiKey>) => {
const newState = {
...tableState,
from: page?.index! * page?.size!,
size: page?.size!,
sort: sort ?? tableState.sort,
};
setTableState(newState);
queryApiKeysAndAggregations(newState);
};
const onSearchChange = (args: EuiSearchBarOnChangeArgs) => {
if (!args.error) {
const newState = {
...tableState,
query: args.query,
};
setTableState(newState);
queryApiKeysAndAggregations(newState);
}
};
const onFilterChange = (filters: QueryFilters) => {
const newState = {
...tableState,
filters: {
...tableState.filters,
...filters,
},
};
setTableState(newState);
queryApiKeysAndAggregations(newState);
};
useEffect(() => {
getApiKeys();
queryApiKeysAndAggregations(DEFAULT_TABLE_STATE);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (!state.value) {
@ -77,7 +133,7 @@ export const APIKeysGridPage: FunctionComponent = () => {
return (
<ApiKeysEmptyPrompt error={state.error}>
<EuiButton iconType="refresh" onClick={() => getApiKeys()}>
<EuiButton iconType="refresh" onClick={() => queryApiKeysAndAggregations(tableState)}>
<FormattedMessage
id="xpack.security.accountManagement.apiKeys.retryButton"
defaultMessage="Try again"
@ -88,10 +144,32 @@ export const APIKeysGridPage: FunctionComponent = () => {
}
const [
{ apiKeys, canManageCrossClusterApiKeys, canManageApiKeys, canManageOwnApiKeys },
{
aggregations,
canManageApiKeys,
apiKeys,
canManageOwnApiKeys,
canManageCrossClusterApiKeys,
aggregationTotal: totalKeys,
total: filteredItemTotal,
queryError,
},
currentUser,
] = state.value;
const categorizedApiKeys = !queryError
? apiKeys.map((apiKey) => apiKey as CategorizedApiKey)
: [];
const displayedItemCount = Math.min(filteredItemTotal, totalKeys, MAX_PAGINATED_ITEMS);
const pagination = {
pageIndex: tableState.from / tableState.size,
pageSize: tableState.size,
totalItemCount: displayedItemCount,
pageSizeOptions: [25, 50, 100],
};
return (
<>
<Route path="/create">
@ -105,7 +183,7 @@ export const APIKeysGridPage: FunctionComponent = () => {
onSuccess={(createApiKeyResponse) => {
history.push({ pathname: '/' });
setCreatedApiKey(createApiKeyResponse);
getApiKeys();
queryApiKeysAndAggregations(tableState);
}}
onCancel={() => history.push({ pathname: '/' })}
canManageCrossClusterApiKeys={canManageCrossClusterApiKeys}
@ -125,15 +203,14 @@ export const APIKeysGridPage: FunctionComponent = () => {
});
setOpenedApiKey(undefined);
getApiKeys();
queryApiKeysAndAggregations(DEFAULT_TABLE_STATE);
}}
onCancel={() => setOpenedApiKey(undefined)}
apiKey={openedApiKey}
readOnly={readOnly}
/>
)}
{!apiKeys.length ? (
{totalKeys === 0 ? (
<ApiKeysEmptyPrompt readOnly={readOnly}>
<EuiButton
{...reactRouterNavigate(history, '/create')}
@ -159,7 +236,7 @@ export const APIKeysGridPage: FunctionComponent = () => {
description={
<FormattedMessage
id="xpack.security.management.apiKeys.table.apiKeysAllDescription"
defaultMessage="Allow external services to access your Elastic Stack."
defaultMessage="Allow external services to access the Elastic Stack on behalf of a user."
/>
}
rightSideItems={
@ -184,7 +261,7 @@ export const APIKeysGridPage: FunctionComponent = () => {
/>
<EuiSpacer />
<KibanaPageTemplate.Section paddingSize="none">
{createdApiKey && !state.loading && (
{createdApiKey && (
<>
<ApiKeyCreatedCallout createdApiKey={createdApiKey} />
<EuiSpacer />
@ -212,12 +289,14 @@ export const APIKeysGridPage: FunctionComponent = () => {
>
{(invalidateApiKeyPrompt) => (
<ApiKeysTable
apiKeys={apiKeys}
apiKeys={categorizedApiKeys}
onClick={(apiKey) => setOpenedApiKey(apiKey)}
query={tableState.query}
queryFilters={tableState.filters}
onDelete={(apiKeysToDelete) =>
invalidateApiKeyPrompt(
apiKeysToDelete.map(({ name, id }) => ({ name, id })),
getApiKeys
() => queryApiKeysAndAggregations(tableState)
)
}
currentUser={currentUser}
@ -227,6 +306,15 @@ export const APIKeysGridPage: FunctionComponent = () => {
canManageOwnApiKeys={canManageOwnApiKeys}
readOnly={readOnly}
loading={state.loading}
totalItemCount={filteredItemTotal}
pagination={pagination}
onTableChange={onTableChange}
onSearchChange={onSearchChange}
onFilterChange={onFilterChange}
aggregations={aggregations}
sortingOptions={tableState.sort}
queryErrors={queryError}
resetQuery={resetQueryOnError}
/>
)}
</InvalidateProvider>
@ -300,570 +388,3 @@ export const ApiKeyCreatedCallout: FunctionComponent<ApiKeyCreatedCalloutProps>
</EuiCallOut>
);
};
export interface ApiKeysTableProps {
apiKeys: ApiKey[];
currentUser: AuthenticatedUser;
createdApiKey?: CreateAPIKeyResult;
readOnly?: boolean;
loading?: boolean;
canManageCrossClusterApiKeys: boolean;
canManageApiKeys: boolean;
canManageOwnApiKeys: boolean;
onClick(apiKey: CategorizedApiKey): void;
onDelete(apiKeys: CategorizedApiKey[]): void;
}
export const ApiKeysTable: FunctionComponent<ApiKeysTableProps> = ({
apiKeys,
createdApiKey,
currentUser,
onClick,
onDelete,
canManageApiKeys = false,
canManageOwnApiKeys = false,
readOnly = false,
loading = false,
}) => {
const columns: Array<EuiBasicTableColumn<CategorizedApiKey>> = [];
const [selectedItems, setSelectedItems] = useState<CategorizedApiKey[]>([]);
const { categorizedApiKeys, typeFilters, usernameFilters, expiredFilters } = useMemo(
() => categorizeApiKeys(apiKeys),
[apiKeys]
);
const deletable = (item: CategorizedApiKey) =>
canManageApiKeys || (canManageOwnApiKeys && item.username === currentUser.username);
columns.push(
{
field: 'name',
name: i18n.translate('xpack.security.management.apiKeys.table.nameColumnName', {
defaultMessage: 'Name',
}),
sortable: true,
render: (name: string, item: CategorizedApiKey) => {
return (
<EuiLink onClick={() => onClick(item)} data-test-subj={`apiKeyRowName-${item.name}`}>
{name}
</EuiLink>
);
},
},
{
field: 'type',
name: i18n.translate('xpack.security.management.apiKeys.table.typeColumnName', {
defaultMessage: 'Type',
}),
sortable: true,
render: (type: CategorizedApiKey['type']) => <ApiKeyBadge type={type} />,
}
);
if (canManageApiKeys || usernameFilters.length > 1) {
columns.push({
field: 'username',
name: i18n.translate('xpack.security.management.apiKeys.table.ownerColumnName', {
defaultMessage: 'Owner',
}),
sortable: true,
render: (username: CategorizedApiKey['username']) => <UsernameWithIcon username={username} />,
});
}
columns.push(
{
field: 'creation',
name: i18n.translate('xpack.security.management.apiKeys.table.createdColumnName', {
defaultMessage: 'Created',
}),
sortable: true,
mobileOptions: {
show: false,
},
render: (creation: number, item: CategorizedApiKey) => (
<TimeToolTip timestamp={creation}>
{item.id === createdApiKey?.id ? (
<EuiBadge color="success">
<FormattedMessage
id="xpack.security.management.apiKeys.table.createdBadge"
defaultMessage="Just now"
/>
</EuiBadge>
) : null}
</TimeToolTip>
),
},
{
field: 'expiration',
name: i18n.translate('xpack.security.management.apiKeys.table.statusColumnName', {
defaultMessage: 'Status',
}),
sortable: true,
render: (expiration: number) => <ApiKeyStatus expiration={expiration} />,
}
);
if (!readOnly) {
columns.push({
width: `${24 + 2 * 8}px`,
actions: [
{
name: i18n.translate('xpack.security.management.apiKeys.table.deleteAction', {
defaultMessage: 'Delete',
}),
description: i18n.translate('xpack.security.management.apiKeys.table.deleteDescription', {
defaultMessage: 'Delete this API key',
}),
icon: 'trash',
type: 'icon',
color: 'danger',
onClick: (item) => onDelete([item]),
available: deletable,
'data-test-subj': 'apiKeysTableDeleteAction',
},
],
});
}
const filters: SearchFilterConfig[] = [];
if (typeFilters.length > 1) {
filters.push({
type: 'custom_component',
component: ({ query, onChange }) => (
<TypesFilterButton types={typeFilters} query={query} onChange={onChange} />
),
});
}
if (usernameFilters.length > 1) {
filters.push({
type: 'custom_component',
component: ({ query, onChange }) => (
<UsersFilterButton usernames={usernameFilters} query={query} onChange={onChange} />
),
});
}
if (expiredFilters.length > 1) {
filters.push({
type: 'field_value_toggle_group',
field: 'expired',
items: [
{
value: false,
name: i18n.translate('xpack.security.management.apiKeys.table.activeFilter', {
defaultMessage: 'Active',
}),
},
{
value: true,
name: i18n.translate('xpack.security.management.apiKeys.table.expiredFilter', {
defaultMessage: 'Expired',
}),
},
],
});
}
return (
<EuiInMemoryTable
items={categorizedApiKeys}
itemId="id"
columns={columns}
search={
categorizedApiKeys.length > 0
? {
toolsLeft: selectedItems.length ? (
<EuiButton
onClick={() => onDelete(selectedItems)}
color="danger"
iconType="trash"
data-test-subj="bulkInvalidateActionButton"
>
<FormattedMessage
id="xpack.security.management.apiKeys.table.invalidateApiKeyButton"
defaultMessage="Delete {count, plural, one {API key} other {# API keys}}"
values={{
count: selectedItems.length,
}}
/>
</EuiButton>
) : undefined,
box: {
incremental: true,
},
filters,
}
: undefined
}
sorting={{
sort: {
field: 'creation',
direction: 'desc',
},
}}
selection={
readOnly
? undefined
: {
selectable: deletable,
onSelectionChange: setSelectedItems,
}
}
pagination={{
initialPageSize: 10,
pageSizeOptions: [10, 25, 50],
}}
loading={loading}
/>
);
};
export interface TypesFilterButtonProps {
query: Query;
onChange?: (query: Query) => void;
types: string[];
}
export const TypesFilterButton: FunctionComponent<TypesFilterButtonProps> = ({
query,
onChange,
types,
}) => {
if (!onChange) {
return null;
}
return (
<>
{types.includes('rest') ? (
<EuiFilterButton
iconType="user"
iconSide="left"
hasActiveFilters={query.hasSimpleFieldClause('type', 'rest')}
onClick={() =>
onChange(
query.hasSimpleFieldClause('type', 'rest')
? query.removeSimpleFieldClauses('type')
: query.removeSimpleFieldClauses('type').addSimpleFieldValue('type', 'rest')
)
}
withNext={types.includes('cross_cluster') || types.includes('managed')}
>
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.restTitle"
defaultMessage="User"
/>
</EuiFilterButton>
) : null}
{types.includes('cross_cluster') ? (
<EuiFilterButton
iconType="cluster"
iconSide="left"
hasActiveFilters={query.hasSimpleFieldClause('type', 'cross_cluster')}
onClick={() =>
onChange(
query.hasSimpleFieldClause('type', 'cross_cluster')
? query.removeSimpleFieldClauses('type')
: query
.removeSimpleFieldClauses('type')
.addSimpleFieldValue('type', 'cross_cluster')
)
}
withNext={types.includes('managed')}
>
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.crossClusterLabel"
defaultMessage="Cross-cluster"
/>
</EuiFilterButton>
) : null}
{types.includes('managed') ? (
<EuiFilterButton
iconType="gear"
iconSide="left"
hasActiveFilters={query.hasSimpleFieldClause('type', 'managed')}
onClick={() =>
onChange(
query.hasSimpleFieldClause('type', 'managed')
? query.removeSimpleFieldClauses('type')
: query.removeSimpleFieldClauses('type').addSimpleFieldValue('type', 'managed')
)
}
>
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.managedTitle"
defaultMessage="Managed"
/>
</EuiFilterButton>
) : null}
</>
);
};
export interface UsersFilterButtonProps {
query: Query;
onChange?: (query: Query) => void;
usernames: string[];
}
export const UsersFilterButton: FunctionComponent<UsersFilterButtonProps> = ({
query,
onChange,
usernames,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
if (!onChange) {
return null;
}
let numActiveFilters = 0;
const clause = query.getOrFieldClause('username');
if (clause) {
if (Array.isArray(clause.value)) {
numActiveFilters = clause.value.length;
} else {
numActiveFilters = 1;
}
}
const usernamesMatchingSearchTerm = searchTerm
? usernames.filter((username) => username.includes(searchTerm))
: usernames;
return (
<UserProfilesPopover
button={
<EuiFilterButton
iconType="arrowDown"
onClick={() => setIsOpen((toggle) => !toggle)}
isSelected={isOpen}
numFilters={usernames.length}
hasActiveFilters={numActiveFilters ? true : false}
numActiveFilters={numActiveFilters}
>
<FormattedMessage
id="xpack.security.management.apiKeys.table.ownerFilter"
defaultMessage="Owner"
/>
</EuiFilterButton>
}
isOpen={isOpen}
panelPaddingSize="none"
anchorPosition="downCenter"
panelClassName="euiFilterGroup__popoverPanel"
closePopover={() => setIsOpen(false)}
selectableProps={{
options: usernamesMatchingSearchTerm.map((username) => ({
uid: username,
user: { username },
enabled: false,
data: {},
})),
onSearchChange: setSearchTerm,
selectedOptions: usernames
.filter((username) => query.hasOrFieldClause('username', username))
.map((username) => ({
uid: username,
user: { username },
enabled: false,
data: {},
})),
onChange: (nextSelectedOptions) => {
const nextQuery = nextSelectedOptions.reduce(
(acc, option) => acc.addOrFieldValue('username', option.user.username),
query.removeOrFieldClauses('username')
);
onChange(nextQuery);
},
}}
/>
);
};
export type UsernameWithIconProps = Pick<CategorizedApiKey, 'username'>;
export const UsernameWithIcon: FunctionComponent<UsernameWithIconProps> = ({ username }) => (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<UserAvatar user={{ username }} size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" data-test-subj="apiKeyUsername">
{username}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
export interface TimeToolTipProps {
timestamp: number;
}
export const TimeToolTip: FunctionComponent<PropsWithChildren<TimeToolTipProps>> = ({
timestamp,
children,
}) => {
return (
<EuiToolTip content={moment(timestamp).format('LLL')}>
<span>{children ?? moment(timestamp).fromNow()}</span>
</EuiToolTip>
);
};
export type ApiKeyStatusProps = Pick<CategorizedApiKey, 'expiration'>;
export const ApiKeyStatus: FunctionComponent<ApiKeyStatusProps> = ({ expiration }) => {
if (!expiration) {
return (
<EuiHealth color="primary" data-test-subj="apiKeyStatus">
<FormattedMessage
id="xpack.security.management.apiKeys.table.statusActive"
defaultMessage="Active"
/>
</EuiHealth>
);
}
if (Date.now() > expiration) {
return (
<EuiHealth color="subdued" data-test-subj="apiKeyStatus">
<FormattedMessage
id="xpack.security.management.apiKeys.table.statusExpired"
defaultMessage="Expired"
/>
</EuiHealth>
);
}
return (
<EuiHealth color="warning" data-test-subj="apiKeyStatus">
<TimeToolTip timestamp={expiration}>
<FormattedMessage
id="xpack.security.management.apiKeys.table.statusExpires"
defaultMessage="Expires {timeFromNow}"
values={{
timeFromNow: moment(expiration).fromNow(),
}}
/>
</TimeToolTip>
</EuiHealth>
);
};
export interface ApiKeyBadgeProps {
type: CategorizedApiKeyType;
}
export const ApiKeyBadge: FunctionComponent<ApiKeyBadgeProps> = ({ type }) => {
return type === 'cross_cluster' ? (
<EuiToolTip
content={
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.crossClusterDescription"
defaultMessage="Allows remote clusters to connect to your local cluster."
/>
}
>
<EuiBadge color="hollow" iconType="cluster">
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.crossClusterLabel"
defaultMessage="Cross-cluster"
/>
</EuiBadge>
</EuiToolTip>
) : type === 'managed' ? (
<EuiToolTip
content={
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.managedDescription"
defaultMessage="Created and managed by Kibana to correctly run background tasks."
/>
}
>
<EuiBadge color="hollow" iconType="gear">
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.managedTitle"
defaultMessage="Managed"
/>
</EuiBadge>
</EuiToolTip>
) : (
<EuiToolTip
content={
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.restDescription"
defaultMessage="Allows external services to access the Elastic Stack on behalf of a user."
/>
}
>
<EuiBadge color="hollow" iconType="user">
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.restTitle"
defaultMessage="User"
/>
</EuiBadge>
</EuiToolTip>
);
};
/**
* Interface representing a REST API key that is managed by Kibana.
*/
export interface ManagedApiKey extends Omit<RestApiKey, 'type'> {
type: 'managed';
}
/**
* Interface representing an API key the way it is presented in the Kibana UI (with Kibana system
* API keys given its own dedicated `managed` type).
*/
export type CategorizedApiKey = (ApiKey | ManagedApiKey) & {
expired: boolean;
};
/**
* Categorizes API keys by type (with Kibana system API keys given its own dedicated `managed` type)
* and determines applicable filter values.
*/
export function categorizeApiKeys(apiKeys: ApiKey[]) {
const categorizedApiKeys: CategorizedApiKey[] = [];
const typeFilters: Set<CategorizedApiKey['type']> = new Set();
const usernameFilters: Set<CategorizedApiKey['username']> = new Set();
const expiredFilters: Set<CategorizedApiKey['expired']> = new Set();
apiKeys.forEach((apiKey) => {
const type = getApiKeyType(apiKey);
const expired = apiKey.expiration ? Date.now() > apiKey.expiration : false;
typeFilters.add(type);
usernameFilters.add(apiKey.username);
expiredFilters.add(expired);
categorizedApiKeys.push({ ...apiKey, type, expired } as CategorizedApiKey);
});
return {
categorizedApiKeys,
typeFilters: [...typeFilters],
usernameFilters: [...usernameFilters],
expiredFilters: [...expiredFilters],
};
}
export type CategorizedApiKeyType = ReturnType<typeof getApiKeyType>;
/**
* Determines API key type the way it is presented in the UI with Kibana system API keys given its own dedicated `managed` type.
*/
export function getApiKeyType(apiKey: ApiKey) {
return apiKey.type === 'rest' &&
(apiKey.metadata?.managed || apiKey.name.indexOf('Alerting: ') === 0)
? 'managed'
: apiKey.type;
}

View file

@ -0,0 +1,736 @@
/*
* 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 type { estypes } from '@elastic/elasticsearch';
import type {
Criteria,
EuiBasicTableColumn,
EuiSearchBarOnChangeArgs,
Query,
SearchFilterConfig,
} from '@elastic/eui';
import {
EuiBadge,
EuiBasicTable,
EuiButton,
EuiFilterButton,
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiLink,
EuiSearchBar,
EuiSpacer,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import type { CustomComponentProps } from '@elastic/eui/src/components/search_bar/filters/custom_component_filter';
import moment from 'moment-timezone';
import type { FunctionComponent } from 'react';
import React, { createContext, useContext, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { UserAvatar, UserProfilesPopover } from '@kbn/user-profile-components';
import { ApiKeysEmptyPrompt, doesErrorIndicateBadQuery } from './api_keys_empty_prompt';
import type { AuthenticatedUser } from '../../../../common';
import type { ApiKey, ApiKeyAggregations, BaseApiKey } from '../../../../common/model';
import type { CreateAPIKeyResult, QueryApiKeySortOptions } from '../api_keys_api_client';
export interface TablePagination {
pageIndex: number;
pageSize?: number;
totalItemCount: number;
pageSizeOptions?: number[];
showPerPageOptions?: boolean;
}
export interface ApiKeysTableProps {
apiKeys: CategorizedApiKey[];
queryFilters: QueryFilters;
currentUser: AuthenticatedUser;
createdApiKey?: CreateAPIKeyResult;
query: Query;
readOnly?: boolean;
loading?: boolean;
canManageCrossClusterApiKeys: boolean;
canManageApiKeys: boolean;
canManageOwnApiKeys: boolean;
onClick(apiKey: CategorizedApiKey): void;
onDelete(apiKeys: CategorizedApiKey[]): void;
totalItemCount?: number;
onTableChange: ({ page, sort }: Criteria<CategorizedApiKey>) => void;
pagination: TablePagination;
onSearchChange: (args: EuiSearchBarOnChangeArgs) => boolean | void;
aggregations?: ApiKeyAggregations;
sortingOptions: QueryApiKeySortOptions;
queryErrors?: Error;
resetQuery: () => void;
onFilterChange: (filters: QueryFilters) => void;
}
export interface QueryFilters {
usernames?: string[];
type?: 'rest' | 'managed' | 'cross_cluster';
expired?: boolean;
}
export const MAX_PAGINATED_ITEMS = 10000;
const FiltersContext = createContext<{
types: string[];
usernames: string[];
filters: QueryFilters;
onFilterChange: (filters: QueryFilters) => void;
}>({ types: [], usernames: [], filters: {}, onFilterChange: () => {} });
export const ApiKeysTable: FunctionComponent<ApiKeysTableProps> = ({
apiKeys,
createdApiKey,
currentUser,
onClick,
onDelete,
canManageApiKeys = false,
canManageOwnApiKeys = false,
readOnly = false,
loading = false,
totalItemCount = 0,
onTableChange,
pagination,
onSearchChange,
aggregations,
sortingOptions,
queryErrors,
resetQuery,
query,
queryFilters,
onFilterChange,
}) => {
const columns: Array<EuiBasicTableColumn<CategorizedApiKey>> = [];
const [selectedItems, setSelectedItems] = useState<CategorizedApiKey[]>([]);
const { typeFilters, usernameFilters, expired } = categorizeAggregations(aggregations);
const deletable = (item: CategorizedApiKey) =>
canManageApiKeys || (canManageOwnApiKeys && item.username === currentUser.username);
const isBadRequest = queryErrors && doesErrorIndicateBadQuery(queryErrors);
const itemsToDisplay = isBadRequest ? [] : apiKeys;
columns.push(
{
field: 'name',
name: (
<FormattedMessage
id="xpack.security.management.apiKeys.table.nameColumnName"
defaultMessage="Name"
/>
),
sortable: true,
render: (name: string, item: CategorizedApiKey) => {
return (
<EuiLink onClick={() => onClick(item)} data-test-subj={`apiKeyRowName-${item.name}`}>
{name}
</EuiLink>
);
},
},
{
field: 'type',
name: (
<FormattedMessage
id="xpack.security.management.apiKeys.table.typeColumnName"
defaultMessage="Type"
/>
),
sortable: true,
render: (type: CategorizedApiKey['type'], apiKeyRecord) => {
let keyType = type;
if (
apiKeyRecord.name.indexOf('Alerting: ') === 0 ||
apiKeyRecord.metadata?.managed === true
) {
keyType = 'managed';
}
return <ApiKeyBadge type={keyType} />;
},
}
);
if (canManageApiKeys || usernameFilters.length > 1) {
columns.push({
field: 'username',
name: (
<FormattedMessage
id="xpack.security.management.apiKeys.table.ownerColumnName"
defaultMessage="Owner"
/>
),
sortable: true,
render: (username: CategorizedApiKey['username']) => <UsernameWithIcon username={username} />,
});
}
columns.push(
{
field: 'creation',
name: (
<FormattedMessage
id="xpack.security.management.apiKeys.table.createdColumnName"
defaultMessage="Created"
/>
),
sortable: true,
mobileOptions: {
show: false,
},
render: (creation: number, item: CategorizedApiKey) => (
<TimeToolTip timestamp={creation}>
{item.id === createdApiKey?.id ? (
<EuiBadge color="success">
<FormattedMessage
id="xpack.security.management.apiKeys.table.createdBadge"
defaultMessage="Just now"
/>
</EuiBadge>
) : null}
</TimeToolTip>
),
},
{
field: 'expiration',
name: (
<FormattedMessage
id="xpack.security.management.apiKeys.table.statusColumnName"
defaultMessage="Status"
/>
),
sortable: true,
render: (expiration: number) => <ApiKeyStatus expiration={expiration} />,
}
);
if (!readOnly) {
columns.push({
width: `${24 + 2 * 8}px`,
actions: [
{
name: (
<FormattedMessage
id="xpack.security.management.apiKeys.table.deleteAction"
defaultMessage="Delete"
/>
),
description: i18n.translate('xpack.security.management.apiKeys.table.deleteDescription', {
defaultMessage: 'Delete this API key',
}),
icon: 'trash',
type: 'icon',
color: 'danger',
onClick: (item) => onDelete([item]),
available: deletable,
'data-test-subj': 'apiKeysTableDeleteAction',
},
],
});
}
const filters: SearchFilterConfig[] = [];
if (typeFilters.length > 1) {
filters.push({
type: 'custom_component',
component: TypesFilterButton,
});
}
if (expired > 0) {
filters.push({
type: 'custom_component',
component: ExpiredFilterButton,
});
}
if (usernameFilters.length > 1) {
filters.push({
type: 'custom_component',
component: UsersFilterButton,
});
}
const exceededResultCount = totalItemCount > MAX_PAGINATED_ITEMS;
return (
<>
<FiltersContext.Provider
value={{
types: [...typeFilters],
usernames: [...usernameFilters],
filters: queryFilters,
onFilterChange,
}}
>
<EuiSearchBar
query={query}
box={{
incremental: true,
schema: {
strict: true,
fields: {
name: {
type: 'string',
},
type: {
type: 'string',
},
username: {
type: 'string',
},
owner: {
type: 'string',
},
expired: {
type: 'boolean',
},
},
},
}}
filters={filters}
onChange={onSearchChange}
toolsLeft={
selectedItems.length ? (
<EuiButton
onClick={() => onDelete(selectedItems)}
color="danger"
iconType="trash"
data-test-subj="bulkInvalidateActionButton"
>
<FormattedMessage
id="xpack.security.management.apiKeys.table.invalidateApiKeyButton"
defaultMessage="Delete {count, plural, one {API key} other {# API keys}}"
values={{
count: selectedItems.length,
}}
/>
</EuiButton>
) : undefined
}
/>
</FiltersContext.Provider>
<EuiSpacer size="s" />
{isBadRequest ? (
<ApiKeysEmptyPrompt error={queryErrors}>
<EuiButton
iconType="refresh"
onClick={() => {
resetQuery();
}}
>
<FormattedMessage
id="xpack.security.accountManagement.apiKeys.resetQueryButton"
defaultMessage="Reset query"
/>
</EuiButton>
</ApiKeysEmptyPrompt>
) : (
<>
{exceededResultCount && (
<>
<EuiText color="subdued" size="s" data-test-subj="apiKeysTableTooManyResultsLabel">
<FormattedMessage
id="xpack.security.management.apiKeys.table.tooManyResultsLabel"
defaultMessage="Showing {limit} of {totalItemCount, plural, one {# api key} other {# api keys}}"
values={{ totalItemCount, limit: MAX_PAGINATED_ITEMS }}
/>
</EuiText>
<EuiSpacer size="s" />
</>
)}
<EuiBasicTable
items={itemsToDisplay}
itemId="id"
columns={columns}
loading={loading}
pagination={pagination}
onChange={onTableChange}
selection={
readOnly
? undefined
: {
selectable: deletable,
onSelectionChange: setSelectedItems,
}
}
sorting={{
sort: {
field: sortingOptions.field,
direction: sortingOptions.direction,
},
}}
/>
</>
)}
</>
);
};
export const TypesFilterButton: FunctionComponent<CustomComponentProps> = ({ query, onChange }) => {
const { types, filters, onFilterChange } = useContext(FiltersContext);
if (!onChange) {
return null;
}
return (
<>
{types.includes('rest') ? (
<EuiFilterButton
iconType="user"
iconSide="left"
hasActiveFilters={filters.type === 'rest'}
onClick={() => {
onFilterChange({ ...filters, type: filters.type === 'rest' ? undefined : 'rest' });
}}
withNext={types.includes('cross_cluster') || types.includes('managed')}
>
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.restTitle"
defaultMessage="Personal"
/>
</EuiFilterButton>
) : null}
{types.includes('cross_cluster') ? (
<EuiFilterButton
iconType="cluster"
iconSide="left"
hasActiveFilters={filters.type === 'cross_cluster'}
onClick={() => {
onFilterChange({
...filters,
type: filters.type === 'cross_cluster' ? undefined : 'cross_cluster',
});
}}
withNext={types.includes('managed')}
>
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.crossClusterLabel"
defaultMessage="Cross-Cluster"
/>
</EuiFilterButton>
) : null}
{types.includes('managed') ? (
<EuiFilterButton
iconType="gear"
iconSide="left"
hasActiveFilters={filters.type === 'managed'}
onClick={() => {
onFilterChange({
...filters,
type: filters.type === 'managed' ? undefined : 'managed',
});
}}
>
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.managedTitle"
defaultMessage="Managed"
/>
</EuiFilterButton>
) : null}
</>
);
};
export const ExpiredFilterButton: FunctionComponent<CustomComponentProps> = ({
query,
onChange,
}) => {
const { filters, onFilterChange } = useContext(FiltersContext);
if (!onChange) {
return null;
}
return (
<>
<EuiFilterButton
hasActiveFilters={filters.expired === false}
onClick={() => {
if (filters.expired === false) {
onFilterChange({ ...filters, expired: undefined });
} else {
onFilterChange({ ...filters, expired: false });
}
}}
withNext={true}
>
<FormattedMessage
id="xpack.security.management.apiKeys.table.activeFilter"
defaultMessage="Active"
/>
</EuiFilterButton>
<EuiFilterButton
hasActiveFilters={filters.expired === true}
onClick={() => {
if (filters.expired === true) {
onFilterChange({ ...filters, expired: undefined });
} else {
onFilterChange({ ...filters, expired: true });
}
}}
>
<FormattedMessage
id="xpack.security.management.apiKeys.table.expiredFilter"
defaultMessage="Expired"
/>
</EuiFilterButton>
</>
);
};
export const UsersFilterButton: FunctionComponent<CustomComponentProps> = ({ query, onChange }) => {
const { usernames, filters, onFilterChange } = useContext(FiltersContext);
const filteredUsernames = filters.usernames || [];
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
if (!onChange) {
return null;
}
let numActiveFilters = 0;
const clause = filters.usernames || [];
if (clause.length) {
numActiveFilters = clause.length;
}
const usernamesMatchingSearchTerm = searchTerm
? usernames.filter((username) => username.includes(searchTerm))
: usernames;
return (
<UserProfilesPopover
button={
<EuiFilterButton
iconType="arrowDown"
onClick={() => setIsOpen((toggle) => !toggle)}
isSelected={isOpen}
numFilters={usernames.length}
hasActiveFilters={numActiveFilters ? true : false}
numActiveFilters={numActiveFilters}
>
<FormattedMessage
id="xpack.security.management.apiKeys.table.ownerFilter"
defaultMessage="Owner"
/>
</EuiFilterButton>
}
isOpen={isOpen}
panelPaddingSize="none"
anchorPosition="downCenter"
panelClassName="euiFilterGroup__popoverPanel"
closePopover={() => setIsOpen(false)}
selectableProps={{
options: usernamesMatchingSearchTerm.map((username) => ({
uid: username,
user: { username },
enabled: false,
data: {},
})),
onSearchChange: setSearchTerm,
selectedOptions: usernames
.filter((username) => filteredUsernames.includes(username))
.map((username) => ({
uid: username,
user: { username },
enabled: false,
data: {},
})),
onChange: (nextSelectedOptions) => {
const nextFilters = nextSelectedOptions.map((option) => option.user.username);
onFilterChange({ ...filters, usernames: nextFilters });
},
}}
/>
);
};
export type UsernameWithIconProps = Pick<CategorizedApiKey, 'username'>;
export const UsernameWithIcon: FunctionComponent<UsernameWithIconProps> = ({ username }) => (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<UserAvatar user={{ username }} size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" data-test-subj="apiKeyUsername">
{username}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
export interface TimeToolTipProps {
timestamp: number;
}
export const TimeToolTip: FunctionComponent<TimeToolTipProps> = ({ timestamp, children }) => {
return (
<EuiToolTip content={moment(timestamp).format('LLL')}>
<span>{children ?? moment(timestamp).fromNow()}</span>
</EuiToolTip>
);
};
export type ApiKeyStatusProps = Pick<CategorizedApiKey, 'expiration'>;
export const ApiKeyStatus: FunctionComponent<ApiKeyStatusProps> = ({ expiration }) => {
if (!expiration) {
return (
<EuiHealth color="primary" data-test-subj="apiKeyStatus">
<FormattedMessage
id="xpack.security.management.apiKeys.table.statusActive"
defaultMessage="Active"
/>
</EuiHealth>
);
}
if (Date.now() > expiration) {
return (
<EuiHealth color="subdued" data-test-subj="apiKeyStatus">
<FormattedMessage
id="xpack.security.management.apiKeys.table.statusExpired"
defaultMessage="Expired"
/>
</EuiHealth>
);
}
return (
<EuiHealth color="warning" data-test-subj="apiKeyStatus">
<TimeToolTip timestamp={expiration}>
<FormattedMessage
id="xpack.security.management.apiKeys.table.statusExpires"
defaultMessage="Expires {timeFromNow}"
values={{
timeFromNow: moment(expiration).fromNow(),
}}
/>
</TimeToolTip>
</EuiHealth>
);
};
export interface ApiKeyBadgeProps {
type: 'rest' | 'cross_cluster' | 'managed';
}
export const ApiKeyBadge: FunctionComponent<ApiKeyBadgeProps> = ({ type }) => {
return type === 'cross_cluster' ? (
<EuiToolTip
content={
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.crossClusterDescription"
defaultMessage="Allows remote clusters to connect to your local cluster."
/>
}
>
<EuiBadge color="hollow" iconType="cluster">
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.crossClusterLabel"
defaultMessage="Cross-Cluster"
/>
</EuiBadge>
</EuiToolTip>
) : type === 'managed' ? (
<EuiToolTip
content={
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.managedDescription"
defaultMessage="Created and managed by Kibana to correctly run background tasks."
/>
}
>
<EuiBadge color="hollow" iconType="gear">
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.managedTitle"
defaultMessage="Managed"
/>
</EuiBadge>
</EuiToolTip>
) : (
<EuiToolTip
content={
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.restDescription"
defaultMessage="Allows external services to access the Elastic Stack on behalf of a user."
/>
}
>
<EuiBadge color="hollow" iconType="user">
<FormattedMessage
id="xpack.security.accountManagement.apiKeyBadge.restTitle"
defaultMessage="Personal"
/>
</EuiBadge>
</EuiToolTip>
);
};
/**
* Interface representing a REST API key that is managed by Kibana.
*/
export interface ManagedApiKey extends BaseApiKey {
type: 'managed';
}
/**
* Interface representing an API key the way it is presented in the Kibana UI (with Kibana system
* API keys given its own dedicated `managed` type).
*/
export type CategorizedApiKey = (ApiKey | ManagedApiKey) & {
expired: boolean;
};
export const categorizeAggregations = (aggregationResponse?: ApiKeyAggregations) => {
const typeFilters: Array<CategorizedApiKey['type']> = [];
const usernameFilters: Array<CategorizedApiKey['username']> = [];
let expiredCount = 0;
if (aggregationResponse && Object.keys(aggregationResponse).length > 0) {
const { usernames, types, expired, managed } = aggregationResponse;
const typeBuckets = types?.buckets.length
? (types.buckets as estypes.AggregationsStringTermsBucket[])
: [];
const usernameBuckets = usernames?.buckets.length
? (usernames.buckets as estypes.AggregationsStringTermsBucket[])
: [];
typeBuckets.forEach((type) => {
typeFilters.push(type.key);
});
usernameBuckets.forEach((username) => {
usernameFilters.push(username.key);
});
const { namePrefixBased, metadataBased } = managed?.buckets || {};
if (
(namePrefixBased?.doc_count && namePrefixBased.doc_count > 0) ||
(metadataBased?.doc_count && metadataBased.doc_count > 0)
) {
typeFilters.push('managed');
}
expiredCount = expired?.doc_count ?? 0;
}
return {
typeFilters,
usernameFilters,
expired: expiredCount,
};
};

View file

@ -32,11 +32,13 @@ describe('apiKeysManagementApp', () => {
},
};
coreStartMock.http.get.mockResolvedValue({
coreStartMock.http.post.mockResolvedValue({
apiKeys: [],
canManageCrossClusterApiKeys: true,
canManageApiKeys: true,
canManageOwnApiKeys: true,
aggregations: {},
aggregationsTotal: 0,
});
authc.getCurrentUser.mockResolvedValue(

View file

@ -45,7 +45,7 @@ export const apiKeysManagementApp = Object.freeze({
async mount({ element, setBreadcrumbs, history }) {
const [[coreStart], { APIKeysGridPage }] = await Promise.all([
getStartServices(),
import('./api_keys_grid'),
import('./api_keys_grid/api_keys_grid_page'),
]);
render(

View file

@ -1,93 +0,0 @@
/*
* 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 type { RouteDefinitionParams } from '..';
import type { ApiKey } from '../../../common/model';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
/**
* Response of Kibana Get API keys endpoint.
*/
export interface GetAPIKeysResult {
apiKeys: ApiKey[];
canManageCrossClusterApiKeys: boolean;
canManageApiKeys: boolean;
canManageOwnApiKeys: boolean;
}
export function defineGetApiKeysRoutes({
router,
getAuthenticationService,
}: RouteDefinitionParams) {
router.get(
{
path: '/internal/security/api_key',
validate: false,
options: {
access: 'internal',
},
},
createLicensedRouteHandler(async (context, request, response) => {
try {
const esClient = (await context.core).elasticsearch.client;
const authenticationService = getAuthenticationService();
const [{ cluster: clusterPrivileges }, areApiKeysEnabled, areCrossClusterApiKeysEnabled] =
await Promise.all([
esClient.asCurrentUser.security.hasPrivileges({
body: {
cluster: [
'manage_security',
'read_security',
'manage_api_key',
'manage_own_api_key',
],
},
}),
authenticationService.apiKeys.areAPIKeysEnabled(),
authenticationService.apiKeys.areCrossClusterAPIKeysEnabled(),
]);
if (!areApiKeysEnabled) {
return response.notFound({
body: {
message:
"API keys are disabled in Elasticsearch. To use API keys enable 'xpack.security.authc.api_key.enabled' setting.",
},
});
}
const apiResponse = await esClient.asCurrentUser.security.getApiKey({
owner: !clusterPrivileges.manage_api_key && !clusterPrivileges.read_security,
});
const validKeys = apiResponse.api_keys
.filter(({ invalidated }) => !invalidated)
.map((key) => {
if (!key.name) {
key.name = key.id;
}
return key;
});
return response.ok<GetAPIKeysResult>({
body: {
// @ts-expect-error Elasticsearch client types do not know about cross-cluster API keys yet.
apiKeys: validKeys,
canManageCrossClusterApiKeys:
clusterPrivileges.manage_security && areCrossClusterApiKeysEnabled,
canManageApiKeys: clusterPrivileges.manage_api_key,
canManageOwnApiKeys: clusterPrivileges.manage_own_api_key,
},
});
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -7,9 +7,9 @@
import { defineCreateApiKeyRoutes } from './create';
import { defineEnabledApiKeysRoutes } from './enabled';
import { defineGetApiKeysRoutes } from './get';
import { defineHasApiKeysRoutes } from './has_active';
import { defineInvalidateApiKeysRoutes } from './invalidate';
import { defineQueryApiKeysAndAggregationsRoute } from './query';
import { defineUpdateApiKeyRoutes } from './update';
import type { RouteDefinitionParams } from '..';
@ -20,13 +20,12 @@ export type {
UpdateCrossClusterAPIKeyParams,
UpdateRestAPIKeyWithKibanaPrivilegesParams,
} from './update';
export type { GetAPIKeysResult } from './get';
export function defineApiKeysRoutes(params: RouteDefinitionParams) {
defineEnabledApiKeysRoutes(params);
defineGetApiKeysRoutes(params);
defineHasApiKeysRoutes(params);
defineCreateApiKeyRoutes(params);
defineUpdateApiKeyRoutes(params);
defineInvalidateApiKeysRoutes(params);
defineQueryApiKeysAndAggregationsRoute(params);
}

View file

@ -13,12 +13,12 @@ import { coreMock, httpServerMock } from '@kbn/core/server/mocks';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import type { DeeplyMockedKeys } from '@kbn/utility-types-jest';
import { defineGetApiKeysRoutes } from './get';
import { defineQueryApiKeysAndAggregationsRoute } from './query';
import type { InternalAuthenticationServiceStart } from '../../authentication';
import { authenticationServiceMock } from '../../authentication/authentication_service.mock';
import { routeDefinitionParamsMock } from '../index.mock';
describe('Get API Keys route', () => {
describe('Query API Keys route', () => {
let routeHandler: RequestHandler<any, any, any, any>;
let authc: DeeplyMockedKeys<InternalAuthenticationServiceStart>;
let esClientMock: ScopedClusterClientMock;
@ -28,8 +28,8 @@ describe('Get API Keys route', () => {
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
authc = authenticationServiceMock.createStart();
mockRouteDefinitionParams.getAuthenticationService.mockReturnValue(authc);
defineGetApiKeysRoutes(mockRouteDefinitionParams);
[[, routeHandler]] = mockRouteDefinitionParams.router.get.mock.calls;
defineQueryApiKeysAndAggregationsRoute(mockRouteDefinitionParams);
[[, routeHandler]] = mockRouteDefinitionParams.router.post.mock.calls;
mockContext = coreMock.createCustomRequestHandlerContext({
core: coreMock.createRequestHandlerContext(),
licensing: licensingMock.createRequestHandlerContext(),
@ -49,64 +49,17 @@ describe('Get API Keys route', () => {
},
} as any);
esClientMock.asCurrentUser.security.getApiKey.mockResponse({
esClientMock.asCurrentUser.security.queryApiKeys.mockResponse({
api_keys: [
{ id: '123', invalidated: false },
{ id: '456', invalidated: true },
],
} as any);
});
it('should filter out invalidated API keys', async () => {
const response = await routeHandler(
mockContext,
httpServerMock.createKibanaRequest(),
kibanaResponseFactory
);
expect(response.status).toBe(200);
expect(response.payload.apiKeys).toContainEqual({ id: '123', name: '123', invalidated: false });
expect(response.payload.apiKeys).not.toContainEqual({
id: '456',
name: '456',
invalidated: true,
});
});
it('should substitute the API key id for keys with `null` names', async () => {
esClientMock.asCurrentUser.security.getApiKey.mockRestore();
esClientMock.asCurrentUser.security.getApiKey.mockResponse({
api_keys: [
{ id: 'with_name', name: 'foo', invalidated: false },
{ id: 'undefined_name', invalidated: false },
{ id: 'null_name', name: null, invalidated: false },
],
} as any);
const response = await routeHandler(
mockContext,
httpServerMock.createKibanaRequest(),
kibanaResponseFactory
);
expect(response.status).toBe(200);
expect(response.payload.apiKeys).toEqual([
{
id: 'with_name',
name: 'foo',
invalidated: false,
},
{
id: 'undefined_name',
name: 'undefined_name',
invalidated: false,
},
{
id: 'null_name',
name: 'null_name',
invalidated: false,
},
]);
esClientMock.asCurrentUser.transport.request.mockImplementation(async (request) => ({
aggregationsTotal: 2,
aggregations: {},
}));
});
it('should return `404` if API keys are disabled', async () => {
@ -125,9 +78,9 @@ describe('Get API Keys route', () => {
});
});
it('should forward error from Elasticsearch GET API keys endpoint', async () => {
it('should forward error from Elasticsearch Query API keys endpoint', async () => {
const error = Boom.forbidden('test not acceptable message');
esClientMock.asCurrentUser.security.getApiKey.mockResponseImplementation(() => {
esClientMock.asCurrentUser.security.queryApiKeys.mockResponseImplementation(() => {
throw error;
});
@ -186,12 +139,6 @@ describe('Get API Keys route', () => {
})
);
});
it('should request list of all Elasticsearch API keys', async () => {
await routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory);
expect(esClientMock.asCurrentUser.security.getApiKey).toHaveBeenCalledWith({ owner: false });
});
});
describe('when user has `manage_api_key` permission', () => {
@ -221,12 +168,6 @@ describe('Get API Keys route', () => {
})
);
});
it('should request list of all Elasticsearch API keys', async () => {
await routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory);
expect(esClientMock.asCurrentUser.security.getApiKey).toHaveBeenCalledWith({ owner: false });
});
});
describe('when user has `read_security` permission', () => {
@ -256,12 +197,6 @@ describe('Get API Keys route', () => {
})
);
});
it('should request list of all Elasticsearch API keys', async () => {
await routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory);
expect(esClientMock.asCurrentUser.security.getApiKey).toHaveBeenCalledWith({ owner: false });
});
});
describe('when user has `manage_own_api_key` permission', () => {
@ -291,11 +226,5 @@ describe('Get API Keys route', () => {
})
);
});
it('should only request list of API keys owned by the user', async () => {
await routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory);
expect(esClientMock.asCurrentUser.security.getApiKey).toHaveBeenCalledWith({ owner: true });
});
});
});

View file

@ -0,0 +1,228 @@
/*
* 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 { RouteDefinitionParams } from '..';
import type { QueryApiKeyResult } from '../../../common/model';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
interface QueryClause {
[key: string]: any;
}
export function defineQueryApiKeysAndAggregationsRoute({
router,
getAuthenticationService,
}: RouteDefinitionParams) {
router.post(
// SECURITY: We don't apply any authorization tags (e.g., access:security) to this route because all actions performed
// on behalf of the user making the request and governed by the user's own cluster privileges.
{
path: '/internal/security/api_key/_query',
validate: {
body: schema.object({
query: schema.maybe(schema.object({}, { unknowns: 'allow' })),
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({
usernames: schema.maybe(schema.arrayOf(schema.string())),
type: schema.maybe(
schema.oneOf([
schema.literal('rest'),
schema.literal('cross_cluster'),
schema.literal('managed'),
])
),
expired: schema.maybe(schema.boolean()),
})
),
}),
},
options: {
access: 'internal',
},
},
createLicensedRouteHandler(async (context, request, response) => {
try {
const esClient = (await context.core).elasticsearch.client;
const authenticationService = getAuthenticationService();
const [{ cluster: clusterPrivileges }, areApiKeysEnabled, areCrossClusterApiKeysEnabled] =
await Promise.all([
esClient.asCurrentUser.security.hasPrivileges({
cluster: ['manage_security', 'read_security', 'manage_api_key', 'manage_own_api_key'],
}),
authenticationService.apiKeys.areAPIKeysEnabled(),
authenticationService.apiKeys.areCrossClusterAPIKeysEnabled(),
]);
if (!areApiKeysEnabled) {
return response.notFound({
body: {
message:
"API keys are disabled in Elasticsearch. To use API keys enable 'xpack.security.authc.api_key.enabled' setting.",
},
});
}
const alertingPrefixFilter = { prefix: { name: { value: 'Alerting: ' } } };
const managedMetadataFilter = { term: { 'metadata.managed': true } };
const { query, size, from, sort, filters } = request.body;
const queryPayload: {
bool: { must: QueryClause[]; should: QueryClause[]; must_not: QueryClause[] };
} = { bool: { must: [], should: [], must_not: [] } };
if (query) {
queryPayload.bool.must.push(query);
}
queryPayload.bool.must.push({ term: { invalidated: false } });
if (filters) {
const { usernames, type, expired } = filters;
if (type === 'managed') {
queryPayload.bool.must.push({
bool: {
should: [alertingPrefixFilter, managedMetadataFilter],
},
});
} else if (type === 'rest' || type === 'cross_cluster') {
queryPayload.bool.must.push({ term: { type } });
queryPayload.bool.must_not.push(alertingPrefixFilter, managedMetadataFilter);
}
if (expired === false) {
// Active API keys are those that have an expiration date in the future or no expiration date at all
const activeKeysDsl = {
bool: {
must: [
{
bool: {
should: [
{
range: {
expiration: {
gt: 'now',
},
},
},
{
bool: {
must_not: {
exists: {
field: 'expiration',
},
},
},
},
],
minimum_should_match: 1,
},
},
],
},
};
queryPayload.bool.must.push(activeKeysDsl);
} else if (expired === true) {
queryPayload.bool.must.push({ range: { expiration: { lte: 'now' } } });
}
if (usernames && usernames.length > 0) {
queryPayload.bool.must.push({ terms: { username: usernames } });
}
}
const transformedSort = sort && [{ [sort.field]: { order: sort.direction } }];
let queryResult: Partial<QueryApiKeyResult>;
try {
const queryResponse = await esClient.asCurrentUser.security.queryApiKeys({
query: queryPayload,
sort: transformedSort,
size,
from,
});
queryResult = {
// @ts-expect-error Elasticsearch client types do not know about Cross-Cluster API keys yet.
apiKeys: queryResponse.api_keys,
total: queryResponse.total,
count: queryResponse.api_keys.length,
};
} catch ({ name, message }) {
queryResult = { queryError: { name, message } };
}
const aggregationResponse = await esClient.asCurrentUser.security.queryApiKeys({
filter_path: [
'total',
'aggregations.usernames.buckets.key',
'aggregations.types.buckets.key',
'aggregations.invalidated.doc_count',
'aggregations.expired.doc_count',
'aggregations.managed.buckets.metadataBased.doc_count',
'aggregations.managed.buckets.namePrefixBased.doc_count',
],
size: 0,
query: { match: { invalidated: false } },
aggs: {
usernames: {
terms: {
field: 'username',
},
},
types: {
terms: {
field: 'type',
},
},
expired: {
filter: {
range: { expiration: { lte: 'now' } },
},
},
// We need this bucket to separately count API keys that were created by the Alerting plugin using only the plugin name
// From v8.14.0, the Alerting plugin will create all keys with the `metadata.managed` field set to `true`
managed: {
filters: {
filters: {
metadataBased: managedMetadataFilter,
namePrefixBased: alertingPrefixFilter,
},
},
},
},
});
return response.ok<QueryApiKeyResult>({
// @ts-expect-error Elasticsearch client types do not know about Cross-Cluster API keys yet.
body: {
aggregationTotal: aggregationResponse.total,
aggregations: aggregationResponse.aggregations,
canManageCrossClusterApiKeys:
clusterPrivileges.manage_security && areCrossClusterApiKeysEnabled,
canManageApiKeys: clusterPrivileges.manage_api_key,
canManageOwnApiKeys: clusterPrivileges.manage_own_api_key,
...queryResult,
},
});
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -632,7 +632,8 @@ export default function userManagedApiKeyTest({ getService }: FtrProviderContext
const generatedApiKeyName = generateAPIKeyName(ruleTypeId, ruleName);
const { body: allApiKeys } = await supertest
.get(`/internal/security/api_key?isAdmin=true`)
.post(`/internal/security/api_key/_query`)
.send({ query: { match: { name: generatedApiKeyName } } })
.set('kbn-xsrf', 'foo')
.expect(200);

View file

@ -180,13 +180,14 @@ export default function ({ getService }: FtrProviderContext) {
expect(updated).to.eql(true);
});
const getResult = await supertest
.get('/internal/security/api_key')
const queryResult = await supertest
.post('/internal/security/api_key/_query')
.send({})
.set('kbn-xsrf', 'xxx')
.send();
.expect(200);
expect(getResult.body.apiKeys).to.not.be(undefined);
const updatedKey = getResult.body.apiKeys.find(
expect(queryResult.body.apiKeys).to.not.be(undefined);
const updatedKey = queryResult.body.apiKeys.find(
(apiKey: { id: string }) => apiKey.id === id
);
expect(updatedKey).to.not.be(undefined);
@ -257,13 +258,14 @@ export default function ({ getService }: FtrProviderContext) {
if (!basic) {
expect(updateResult.body.updated).to.be(true);
const getResult = await supertest
.get('/internal/security/api_key')
const queryResult = await supertest
.post('/internal/security/api_key/_query')
.send({})
.set('kbn-xsrf', 'xxx')
.send();
.expect(200);
expect(getResult.body.apiKeys).to.not.be(undefined);
const updatedKey = getResult.body.apiKeys.find(
expect(queryResult.body.apiKeys).to.not.be(undefined);
const updatedKey = queryResult.body.apiKeys.find(
(apiKey: { id: string }) => apiKey.id === id
);
expect(updatedKey).to.not.be(undefined);

View file

@ -17,6 +17,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./builtin_es_privileges'));
loadTestFile(require.resolve('./change_password'));
loadTestFile(require.resolve('./index_fields'));
loadTestFile(require.resolve('./query_api_keys'));
loadTestFile(require.resolve('./roles'));
loadTestFile(require.resolve('./users'));
loadTestFile(require.resolve('./privileges'));

View file

@ -0,0 +1,300 @@
/*
* 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 expect from '@kbn/expect';
import { setTimeout as setTimeoutAsync } from 'timers/promises';
import type { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const config = getService('config');
const isBasicLicense = config.get('esTestCluster.license') === 'basic';
const createKey = async (name: string, type: string, managed: boolean, expiry?: string) => {
const access =
type === 'cross_cluster'
? {
search: [
{
names: ['*'],
},
],
replication: [
{
names: ['*'],
},
],
}
: undefined;
const metadata = managed ? { managed: true } : {};
const { body: apiKey } = await supertest
.post('/internal/security/api_key')
.set('kbn-xsrf', 'xxx')
.send({
name,
type,
metadata,
...(access ? { access } : {}),
...(expiry ? { expiration: expiry } : {}),
})
.expect(200);
expect(apiKey.name).to.eql(name);
return apiKey;
};
const createCrossClusterKeys = async () => {
// Create 10 'cross_cluster' keys
return Array.from({ length: 10 }, (_, i) => i).map(
async (i) => await createKey(`cross_cluster_key_${i}`, 'cross_cluster', false)
);
};
const createExpiredKeys = async () => {
const restKeys = Array.from({ length: 10 }, (_, i) => i).map(
async (i) => await createKey(`rest_key_${i}`, 'rest', false)
);
const expiredKeys = Array.from({ length: 5 }, (_, i) => i).map(
async (i) => await createKey(`rest_key_${i}`, 'rest', false, '1s')
);
await Promise.all([...restKeys, ...expiredKeys]);
};
const createMultipleKeys = async () => {
const restKeys = Array.from({ length: 5 }, (_, i) => i).map(
async (i) => await createKey(`rest_key_${i}`, 'rest', false)
);
const alertingKeys = Array.from({ length: 5 }, (_, i) => i).map(async (i) => {
const randomString = Math.random().toString(36).substring(7); // Generate a random string
return await createKey(`Alerting: ${randomString}`, 'rest', false);
});
const metadataManagedKeys = Array.from({ length: 5 }, (_, i) => i).map(async (i) => {
const randomString = Math.random().toString(36).substring(7); // Generate a random string
await createKey(`Managed_metadata_${randomString}`, 'rest', true);
});
const crossClusterKeys = isBasicLicense ? [] : await createCrossClusterKeys();
await Promise.all([...crossClusterKeys, ...restKeys, ...alertingKeys, ...metadataManagedKeys]);
};
const cleanup = async () => {
await getService('es').deleteByQuery({
index: '.security-7',
body: { query: { match: { doc_type: 'api_key' } } },
refresh: true,
});
};
describe('Has queryable API Keys', () => {
beforeEach(cleanup);
afterEach(cleanup);
it('should return all the keys', async () => {
await createMultipleKeys();
const { body: keys } = await supertest
.post('/internal/security/api_key/_query')
.set('kbn-xsrf', 'xxx')
.send({
from: 0,
size: 100,
})
.expect(200);
if (!isBasicLicense) {
expect(keys.apiKeys.length).to.be(25);
} else {
expect(keys.apiKeys.length).to.be(15);
}
});
it('should paginate keys', async () => {
await createKey('first-api-key', 'rest', false);
await createKey('second-api-key', 'rest', false);
const { body: keys } = await supertest
.post('/internal/security/api_key/_query')
.set('kbn-xsrf', 'xxx')
.send({
from: 0,
size: 1,
})
.expect(200);
expect(keys.apiKeys.length).to.be(1);
expect(keys.total).to.be(2);
expect(keys.apiKeys[0].name).to.be('first-api-key');
const { body: paginatedKeys } = await supertest
.post('/internal/security/api_key/_query')
.set('kbn-xsrf', 'xxx')
.send({
from: 1,
size: 1,
})
.expect(200);
expect(keys.apiKeys.length).to.be(1);
expect(keys.total).to.be(2);
expect(paginatedKeys.apiKeys[0].name).to.be('second-api-key');
});
it('should return the correct aggregations', async () => {
await createMultipleKeys();
const { body: aggregationResponse } = await supertest
.post('/internal/security/api_key/_query')
.set('kbn-xsrf', 'xxx')
.send({
query: { match: { invalidated: false } },
})
.expect(200);
expect(aggregationResponse.aggregations).to.have.property('usernames');
expect(aggregationResponse.aggregations.usernames.buckets.length).to.be(1);
expect(aggregationResponse.aggregations).to.have.property('managed');
expect(aggregationResponse.aggregations.managed.buckets.metadataBased.doc_count).to.eql(5);
if (!isBasicLicense) {
expect(aggregationResponse.total).to.be(25);
expect(aggregationResponse.aggregations).to.have.property('types');
expect(aggregationResponse.aggregations.types.buckets.length).to.be(2);
} else {
expect(aggregationResponse.total).to.be(15);
expect(aggregationResponse.aggregations).to.have.property('types');
expect(aggregationResponse.aggregations.types.buckets.length).to.be(1);
}
});
it('should query API keys with custom queries', async () => {
await createMultipleKeys();
const { body: keys } = await supertest
.post('/internal/security/api_key/_query')
.set('kbn-xsrf', 'xxx')
.send({
query: {
bool: {
filter: [
{
term: {
type: 'rest',
},
},
],
},
},
})
.expect(200);
expect(keys.apiKeys.length).to.be(10);
keys.apiKeys.forEach((key: any) => {
expect(key.type).to.be('rest');
});
});
it('should query API keys with filters', async () => {
await createMultipleKeys();
const { body: restKeys } = await supertest
.post('/internal/security/api_key/_query')
.set('kbn-xsrf', 'xxx')
.send({
query: {
match_all: {},
},
filters: {
type: 'rest',
},
})
.expect(200);
expect(restKeys.apiKeys.length).to.be(5);
restKeys.apiKeys.forEach((key: any) => {
expect(key.type).to.be('rest');
});
const { body: managedKeys } = await supertest
.post('/internal/security/api_key/_query')
.set('kbn-xsrf', 'xxx')
.send({
query: {
match_all: {},
},
filters: {
type: 'managed',
},
})
.expect(200);
expect(managedKeys.apiKeys.length).to.be(10);
const alertingNameKeys = managedKeys.apiKeys.filter((key: any) =>
key.name.startsWith('Alerting:')
);
expect(alertingNameKeys.length).to.be(5);
});
it('should correctly filter active and expired keys', async () => {
await createExpiredKeys();
const { body: activeKeys } = await supertest
.post('/internal/security/api_key/_query')
.set('kbn-xsrf', 'xxx')
.send({
query: {
match_all: {},
},
filters: {
expired: false,
},
})
.expect(200);
expect(activeKeys.apiKeys.length).to.be(10);
await setTimeoutAsync(2500);
const { body: expiredKeys } = await supertest
.post('/internal/security/api_key/_query')
.set('kbn-xsrf', 'xxx')
.send({
query: {
match_all: {},
},
filters: {
expired: true,
},
})
.expect(200);
expect(expiredKeys.apiKeys.length).to.be(5);
});
it('should correctly filter keys with combined filters', async () => {
await createExpiredKeys();
await setTimeoutAsync(2500);
const { body: expiredRestKeys } = await supertest
.post('/internal/security/api_key/_query')
.set('kbn-xsrf', 'xxx')
.send({
query: {
match_all: {},
},
filters: {
type: 'rest',
expired: true,
},
})
.expect(200);
expect(expiredRestKeys.apiKeys.length).to.be(5);
});
});
}

View file

@ -17,6 +17,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./builtin_es_privileges'));
loadTestFile(require.resolve('./change_password'));
loadTestFile(require.resolve('./index_fields'));
loadTestFile(require.resolve('./query_api_keys'));
loadTestFile(require.resolve('./roles'));
loadTestFile(require.resolve('./users'));
loadTestFile(require.resolve('./privileges_basic'));

View file

@ -6,7 +6,6 @@
*/
import expect from '@kbn/expect';
import type { ApiKey } from '@kbn/security-plugin/common/model';
import type { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
@ -23,17 +22,11 @@ export default function ({ getService }: FtrProviderContext) {
};
const cleanup = async () => {
// get existing keys which would affect test results
const { body: getResponseBody } = await supertest.get('/internal/security/api_key').expect(200);
const apiKeys: ApiKey[] = getResponseBody.apiKeys;
const existing = apiKeys.map(({ id, name }) => ({ id, name }));
// invalidate the keys
await supertest
.post(`/internal/security/api_key/invalidate`)
.set('kbn-xsrf', 'xxx')
.send({ apiKeys: existing, isAdmin: false })
.expect(200, { itemsInvalidated: existing, errors: [] });
await getService('es').deleteByQuery({
index: '.security-7',
body: { query: { match: { doc_type: 'api_key' } } },
refresh: true,
});
};
describe('Has Active API Keys: _has_active', () => {

View file

@ -109,35 +109,6 @@ export default function ({ getService }: FtrProviderContext) {
expect(status).toBe(200);
});
it('get all', async () => {
let body: unknown;
let status: number;
({ body, status } = await supertest
.get('/internal/security/api_key?isAdmin=true')
.set(svlCommonApi.getCommonRequestHeader()));
// expect a rejection because we're not using the internal header
expect(body).toEqual({
statusCode: 400,
error: 'Bad Request',
message: expect.stringContaining(
'method [get] exists but is not available with the current configuration'
),
});
expect(status).toBe(400);
({ body, status } = await supertest
.get('/internal/security/api_key?isAdmin=true')
.set(svlCommonApi.getInternalRequestHeader()));
// expect success because we're using the internal header
expect(body).toEqual(
expect.objectContaining({
apiKeys: expect.arrayContaining([expect.objectContaining({ id: roleMapping.id })]),
})
);
expect(status).toBe(200);
});
it('get enabled', async () => {
let body: unknown;
let status: number;
@ -206,6 +177,25 @@ export default function ({ getService }: FtrProviderContext) {
});
expect(status).toBe(200);
});
it('query', async () => {
const requestBody = {
query: {
bool: { must: [{ match: { invalidated: { query: false, operator: 'and' } } }] },
},
sort: { field: 'creation', direction: 'desc' },
from: 0,
size: 1,
};
const { body } = await supertest
.post('/internal/security/api_key/_query')
.set(svlCommonApi.getInternalRequestHeader())
.send(requestBody)
.expect(200);
expect(body.apiKeys.length).toEqual(1);
});
});
});
});