Skip to content

Commit 7467311

Browse files
authored
[CCM] Add support for API key rotation (elastic#250422)
1 parent 448c47e commit 7467311

13 files changed

Lines changed: 787 additions & 5 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { render, screen, waitFor } from '@testing-library/react';
10+
import userEvent from '@testing-library/user-event';
11+
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
12+
import { of } from 'rxjs';
13+
import { ConnectedServicesPage, type ConnectedServicesPageProps } from '.';
14+
import { useCloudConnectedAppContext } from '../../app_context';
15+
16+
jest.mock('../../app_context');
17+
18+
const mockUseCloudConnectedAppContext = useCloudConnectedAppContext as jest.MockedFunction<
19+
typeof useCloudConnectedAppContext
20+
>;
21+
22+
const renderWithIntl = (component: React.ReactElement) => {
23+
return render(
24+
<IntlProvider locale="en" messages={{}}>
25+
{component}
26+
</IntlProvider>
27+
);
28+
};
29+
30+
describe('ConnectedServicesPage', () => {
31+
const mockRotateApiKey = jest.fn();
32+
const mockAddSuccess = jest.fn();
33+
const mockAddDanger = jest.fn();
34+
35+
const defaultProps: ConnectedServicesPageProps = {
36+
clusterDetails: {
37+
id: 'cluster-123',
38+
name: 'my-cluster',
39+
metadata: {
40+
organization_id: 'org-456',
41+
created_at: '2024-01-01T00:00:00Z',
42+
created_by: 'user@example.com',
43+
subscription: 'active',
44+
},
45+
self_managed_cluster: {
46+
id: 'es-cluster-uuid',
47+
name: 'my-cluster',
48+
version: '8.15.0',
49+
},
50+
license: {
51+
type: 'platinum',
52+
uid: 'license-uid',
53+
},
54+
services: {
55+
eis: {
56+
enabled: false,
57+
support: { supported: true },
58+
},
59+
auto_ops: {
60+
enabled: false,
61+
support: { supported: true },
62+
},
63+
},
64+
},
65+
onServiceUpdate: jest.fn(),
66+
onDisconnect: jest.fn(),
67+
};
68+
69+
beforeEach(() => {
70+
jest.clearAllMocks();
71+
mockRotateApiKey.mockResolvedValue({ data: { success: true } });
72+
73+
mockUseCloudConnectedAppContext.mockReturnValue({
74+
hasConfigurePermission: true,
75+
notifications: {
76+
toasts: {
77+
addSuccess: mockAddSuccess,
78+
addDanger: mockAddDanger,
79+
},
80+
},
81+
docLinks: {
82+
links: {
83+
cloud: {
84+
cloudConnect: 'https://docs.example.com/cloud-connect',
85+
},
86+
},
87+
},
88+
telemetryService: {
89+
trackLinkClicked: jest.fn(),
90+
},
91+
apiService: {
92+
rotateApiKey: mockRotateApiKey,
93+
disconnectCluster: jest.fn().mockResolvedValue({ data: { success: true } }),
94+
},
95+
licensing: {
96+
license$: of({ type: 'platinum' }),
97+
},
98+
} as any);
99+
});
100+
101+
describe('Rotate API key action', () => {
102+
it('should call rotateApiKey and show success toast on happy path', async () => {
103+
renderWithIntl(<ConnectedServicesPage {...defaultProps} />);
104+
105+
// Open the actions popover
106+
const actionsButton = screen.getByRole('button', { name: /actions/i });
107+
await userEvent.click(actionsButton);
108+
109+
// Click the rotate API key menu item
110+
const rotateMenuItem = await screen.findByRole('button', { name: /rotate api key/i });
111+
await userEvent.click(rotateMenuItem);
112+
113+
await waitFor(() => {
114+
expect(mockRotateApiKey).toHaveBeenCalledTimes(1);
115+
});
116+
117+
await waitFor(() => {
118+
expect(mockAddSuccess).toHaveBeenCalledWith({
119+
title: 'API key rotated successfully',
120+
});
121+
});
122+
});
123+
124+
it('should show error toast when rotate API key fails', async () => {
125+
mockRotateApiKey.mockResolvedValue({
126+
error: { message: 'Failed to rotate' },
127+
});
128+
129+
renderWithIntl(<ConnectedServicesPage {...defaultProps} />);
130+
131+
// Open the actions popover
132+
const actionsButton = screen.getByRole('button', { name: /actions/i });
133+
await userEvent.click(actionsButton);
134+
135+
// Click the rotate API key menu item
136+
const rotateMenuItem = await screen.findByRole('button', { name: /rotate api key/i });
137+
await userEvent.click(rotateMenuItem);
138+
139+
await waitFor(() => {
140+
expect(mockAddDanger).toHaveBeenCalledWith({
141+
title: 'Failed to rotate API key',
142+
text: 'Failed to rotate',
143+
});
144+
});
145+
});
146+
});
147+
});

x-pack/platform/plugins/shared/cloud_connect/public/application/components/connected_services/index.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {
1919
EuiContextMenuPanel,
2020
EuiContextMenuItem,
2121
EuiPopover,
22+
EuiFlexGroup,
23+
EuiFlexItem,
24+
EuiLoadingSpinner,
2225
} from '@elastic/eui';
2326
import { FormattedMessage } from '@kbn/i18n-react';
2427
import useObservable from 'react-use/lib/useObservable';
@@ -54,6 +57,7 @@ export const ConnectedServicesPage: React.FC<ConnectedServicesPageProps> = ({
5457
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false);
5558
const [isDisconnectModalVisible, setIsDisconnectModalVisible] = useState(false);
5659
const [isDisconnecting, setIsDisconnecting] = useState(false);
60+
const [isRotatingApiKey, setIsRotatingApiKey] = useState(false);
5761

5862
const localLicense = useObservable(licensing.license$);
5963
const currentLicenseType = localLicense?.type;
@@ -101,7 +105,49 @@ export const ConnectedServicesPage: React.FC<ConnectedServicesPageProps> = ({
101105
onDisconnect();
102106
};
103107

108+
const handleRotateApiKey = async () => {
109+
closeActionsPopover();
110+
setIsRotatingApiKey(true);
111+
112+
const { error } = await apiService.rotateApiKey();
113+
114+
setIsRotatingApiKey(false);
115+
116+
if (error) {
117+
notifications.toasts.addDanger({
118+
title: i18n.translate('xpack.cloudConnect.rotateApiKey.errorTitle', {
119+
defaultMessage: 'Failed to rotate API key',
120+
}),
121+
text: error.message,
122+
});
123+
return;
124+
}
125+
126+
notifications.toasts.addSuccess({
127+
title: i18n.translate('xpack.cloudConnect.rotateApiKey.successTitle', {
128+
defaultMessage: 'API key rotated successfully',
129+
}),
130+
});
131+
};
132+
104133
const actionsMenuItems = [
134+
<EuiContextMenuItem key="rotate" onClick={handleRotateApiKey} disabled={isRotatingApiKey}>
135+
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
136+
<EuiFlexItem grow={false}>
137+
<EuiText size="s">
138+
<FormattedMessage
139+
id="xpack.cloudConnect.connectedServices.actions.rotateApiKey"
140+
defaultMessage="Rotate API key"
141+
/>
142+
</EuiText>
143+
</EuiFlexItem>
144+
{isRotatingApiKey && (
145+
<EuiFlexItem grow={false}>
146+
<EuiLoadingSpinner size="s" />
147+
</EuiFlexItem>
148+
)}
149+
</EuiFlexGroup>
150+
</EuiContextMenuItem>,
105151
<EuiContextMenuItem key="disconnect" onClick={showDisconnectModal}>
106152
<EuiText color="danger" size="s">
107153
<FormattedMessage

x-pack/platform/plugins/shared/cloud_connect/public/application/components/connected_services/migration_section/index.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { css } from '@emotion/react';
1010
import {
1111
EuiTitle,
1212
EuiText,
13+
EuiTextColor,
1314
EuiSpacer,
1415
EuiFlexGroup,
1516
EuiFlexItem,
@@ -145,11 +146,7 @@ export const MigrationSection: React.FC = () => {
145146
paddingSize="l"
146147
layout="horizontal"
147148
title={benefit.title}
148-
description={
149-
<EuiText size="s" color="subdued">
150-
{benefit.description}
151-
</EuiText>
152-
}
149+
description={<EuiTextColor color="subdued">{benefit.description}</EuiTextColor>}
153150
titleSize="xs"
154151
css={css`
155152
box-shadow: none !important;

x-pack/platform/plugins/shared/cloud_connect/public/application/components/connected_services/services_section/details_card.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,31 @@ describe('ServiceCard', () => {
201201
});
202202
});
203203

204+
it('should call onRotateApiKey when Rotate API key is clicked in popover', async () => {
205+
const onRotateApiKey = jest.fn();
206+
renderWithIntl(
207+
<ServiceCard
208+
{...defaultProps}
209+
enabled={true}
210+
serviceUrl="https://example.com"
211+
onRotateApiKey={onRotateApiKey}
212+
/>
213+
);
214+
215+
const moreActionsButton = screen.getByRole('button', { name: /more actions/i });
216+
await userEvent.click(moreActionsButton);
217+
218+
const rotateMenuItem = await screen.findByRole('button', { name: /rotate api key/i });
219+
await userEvent.click(rotateMenuItem);
220+
221+
expect(onRotateApiKey).toHaveBeenCalledTimes(1);
222+
223+
// Popover should close after clicking
224+
await waitFor(() => {
225+
expect(screen.queryByText(/rotate api key/i)).not.toBeInTheDocument();
226+
});
227+
});
228+
204229
it('should call onOpen when Open button is clicked', async () => {
205230
const onOpen = jest.fn();
206231
renderWithIntl(

x-pack/platform/plugins/shared/cloud_connect/public/application/components/connected_services/services_section/details_card.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface ServiceCardProps {
4545
onEnable?: () => void;
4646
onDisable?: () => void;
4747
onOpen?: () => void;
48+
onRotateApiKey?: () => void;
4849
isLoading?: boolean;
4950
isCardDisabled?: boolean;
5051
subscriptionRequired?: boolean;
@@ -104,6 +105,7 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({
104105
onEnable,
105106
onDisable,
106107
onOpen,
108+
onRotateApiKey,
107109
isLoading = false,
108110
isCardDisabled = false,
109111
subscriptionRequired = false,
@@ -331,6 +333,24 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({
331333
);
332334

333335
const menuItems = [
336+
...(onRotateApiKey
337+
? [
338+
<EuiContextMenuItem
339+
key="rotate"
340+
onClick={() => {
341+
closePopover();
342+
onRotateApiKey();
343+
}}
344+
>
345+
<EuiText size="s">
346+
<FormattedMessage
347+
id="xpack.cloudConnect.connectedServices.service.rotateApiKey"
348+
defaultMessage="Rotate API key"
349+
/>
350+
</EuiText>
351+
</EuiContextMenuItem>,
352+
]
353+
: []),
334354
<EuiContextMenuItem
335355
key="disable"
336356
onClick={() => {

x-pack/platform/plugins/shared/cloud_connect/public/application/components/connected_services/services_section/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const ServicesSection: React.FC<ServicesSectionProps> = ({
4040
showDisableModal,
4141
closeDisableModal,
4242
handleEnableServiceByUrl,
43+
handleRotateServiceApiKey,
4344
} = useServiceActions({ onServiceUpdate, services });
4445

4546
// Check if there's an active subscription (active or trial)
@@ -74,6 +75,7 @@ export const ServicesSection: React.FC<ServicesSectionProps> = ({
7475
defaultMessage: 'Elastic Inference Service',
7576
})
7677
),
78+
onRotateApiKey: services.eis?.enabled ? () => handleRotateServiceApiKey('eis') : undefined,
7779
isLoading: loadingService === 'eis' || autoEnablingEis,
7880
subscriptionRequired: services.eis?.subscription?.required,
7981
hasActiveSubscription,

x-pack/platform/plugins/shared/cloud_connect/public/application/components/connected_services/services_section/use_service_actions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,30 @@ export const useServiceActions = ({ onServiceUpdate, services }: UseServiceActio
129129
window.open(url, '_blank');
130130
};
131131

132+
const handleRotateServiceApiKey = async (serviceKey: ServiceType) => {
133+
setLoadingService(serviceKey);
134+
135+
const { error } = await apiService.rotateServiceApiKey(serviceKey);
136+
137+
setLoadingService(null);
138+
139+
if (error) {
140+
notifications.toasts.addDanger({
141+
title: i18n.translate('xpack.cloudConnect.services.rotateApiKey.errorTitle', {
142+
defaultMessage: 'Failed to rotate API key',
143+
}),
144+
text: error.message,
145+
});
146+
return;
147+
}
148+
149+
notifications.toasts.addSuccess({
150+
title: i18n.translate('xpack.cloudConnect.services.rotateApiKey.successTitle', {
151+
defaultMessage: 'Service API key rotated successfully',
152+
}),
153+
});
154+
};
155+
132156
return {
133157
loadingService,
134158
disableModalService,
@@ -137,5 +161,6 @@ export const useServiceActions = ({ onServiceUpdate, services }: UseServiceActio
137161
showDisableModal,
138162
closeDisableModal,
139163
handleEnableServiceByUrl,
164+
handleRotateServiceApiKey,
140165
};
141166
};

x-pack/platform/plugins/shared/cloud_connect/public/lib/api.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export interface DisconnectClusterResponse {
3535
message: string;
3636
}
3737

38+
export interface RotateApiKeyResponse {
39+
success: boolean;
40+
message: string;
41+
}
42+
3843
export class CloudConnectApiService {
3944
constructor(private readonly client: HttpSetup) {}
4045

@@ -84,4 +89,18 @@ export class CloudConnectApiService {
8489
method: 'delete',
8590
});
8691
}
92+
93+
public async rotateApiKey() {
94+
return await this.sendRequest<RotateApiKeyResponse>({
95+
path: `${API_BASE_PATH}/cluster/rotate_api_key`,
96+
method: 'post',
97+
});
98+
}
99+
100+
public async rotateServiceApiKey(serviceKey: string) {
101+
return await this.sendRequest<RotateApiKeyResponse>({
102+
path: `${API_BASE_PATH}/cluster/${serviceKey}/rotate_api_key`,
103+
method: 'post',
104+
});
105+
}
87106
}

0 commit comments

Comments
 (0)