mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
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:
parent
3a0aa1a65b
commit
a834d76f22
24 changed files with 1624 additions and 879 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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(),
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
228
x-pack/plugins/security/server/routes/api_keys/query.ts
Normal file
228
x-pack/plugins/security/server/routes/api_keys/query.ts
Normal 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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'));
|
||||
|
|
300
x-pack/test/api_integration/apis/security/query_api_keys.ts
Normal file
300
x-pack/test/api_integration/apis/security/query_api_keys.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue