Skip to content

Commit fee0dff

Browse files
Expose ability to check if API Keys are enabled (#63454)
* expose ability to check if API Keys are enabled * fix mock * Fix typo in test name * simplify key check * fix privilege check * remove unused variable * address PR feedback Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
1 parent 07c7ee2 commit fee0dff

22 files changed

Lines changed: 479 additions & 87 deletions

x-pack/plugins/security/public/authentication/authentication_service.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export interface AuthenticationServiceSetup {
2525
* Returns currently authenticated user and throws if current user isn't authenticated.
2626
*/
2727
getCurrentUser: () => Promise<AuthenticatedUser>;
28+
29+
/**
30+
* Determines if API Keys are currently enabled.
31+
*/
32+
areAPIKeysEnabled: () => Promise<boolean>;
2833
}
2934

3035
export class AuthenticationService {
@@ -37,11 +42,15 @@ export class AuthenticationService {
3742
const getCurrentUser = async () =>
3843
(await http.get('/internal/security/me', { asSystemRequest: true })) as AuthenticatedUser;
3944

45+
const areAPIKeysEnabled = async () =>
46+
((await http.get('/internal/security/api_key/_enabled')) as { apiKeysEnabled: boolean })
47+
.apiKeysEnabled;
48+
4049
loginApp.create({ application, config, getStartServices, http });
4150
logoutApp.create({ application, http });
4251
loggedOutApp.create({ application, getStartServices, http });
4352
overwrittenSessionApp.create({ application, authc: { getCurrentUser }, getStartServices });
4453

45-
return { getCurrentUser };
54+
return { getCurrentUser, areAPIKeysEnabled };
4655
}
4756
}

x-pack/plugins/security/public/authentication/index.mock.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ import { AuthenticationServiceSetup } from './authentication_service';
99
export const authenticationMock = {
1010
createSetup: (): jest.Mocked<AuthenticationServiceSetup> => ({
1111
getCurrentUser: jest.fn(),
12+
areAPIKeysEnabled: jest.fn(),
1213
}),
1314
};

x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { AuthenticationServiceSetup } from '../authentication_service';
1010

1111
interface CreateDeps {
1212
application: ApplicationSetup;
13-
authc: AuthenticationServiceSetup;
13+
authc: Pick<AuthenticationServiceSetup, 'getCurrentUser'>;
1414
getStartServices: StartServicesAccessor;
1515
}
1616

x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { AuthenticationStatePage } from '../components';
1414

1515
interface Props {
1616
basePath: IBasePath;
17-
authc: AuthenticationServiceSetup;
17+
authc: Pick<AuthenticationServiceSetup, 'getCurrentUser'>;
1818
}
1919

2020
export function OverwrittenSessionPage({ authc, basePath }: Props) {

x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ApiKey, ApiKeyToInvalidate } from '../../../common/model';
1010
interface CheckPrivilegesResponse {
1111
areApiKeysEnabled: boolean;
1212
isAdmin: boolean;
13+
canManage: boolean;
1314
}
1415

1516
interface InvalidateApiKeysResponse {

x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { APIKeysGridPage } from './api_keys_grid_page';
1818
import { coreMock } from '../../../../../../../src/core/public/mocks';
1919
import { apiKeysAPIClientMock } from '../index.mock';
2020

21-
const mock403 = () => ({ body: { statusCode: 403 } });
2221
const mock500 = () => ({ body: { error: 'Internal Server Error', message: '', statusCode: 500 } });
2322

2423
const waitForRender = async (
@@ -48,6 +47,7 @@ describe('APIKeysGridPage', () => {
4847
apiClientMock.checkPrivileges.mockResolvedValue({
4948
isAdmin: true,
5049
areApiKeysEnabled: true,
50+
canManage: true,
5151
});
5252
apiClientMock.getApiKeys.mockResolvedValue({
5353
apiKeys: [
@@ -82,6 +82,7 @@ describe('APIKeysGridPage', () => {
8282
it('renders a callout when API keys are not enabled', async () => {
8383
apiClientMock.checkPrivileges.mockResolvedValue({
8484
isAdmin: true,
85+
canManage: true,
8586
areApiKeysEnabled: false,
8687
});
8788

@@ -95,7 +96,11 @@ describe('APIKeysGridPage', () => {
9596
});
9697

9798
it('renders permission denied if user does not have required permissions', async () => {
98-
apiClientMock.checkPrivileges.mockRejectedValue(mock403());
99+
apiClientMock.checkPrivileges.mockResolvedValue({
100+
canManage: false,
101+
isAdmin: false,
102+
areApiKeysEnabled: true,
103+
});
99104

100105
const wrapper = mountWithIntl(<APIKeysGridPage {...getViewProperties()} />);
101106

@@ -152,6 +157,7 @@ describe('APIKeysGridPage', () => {
152157
beforeEach(() => {
153158
apiClientMock.checkPrivileges.mockResolvedValue({
154159
isAdmin: false,
160+
canManage: true,
155161
areApiKeysEnabled: true,
156162
});
157163

x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
import { i18n } from '@kbn/i18n';
2727
import { FormattedMessage } from '@kbn/i18n/react';
2828
import moment from 'moment-timezone';
29-
import _ from 'lodash';
3029
import { NotificationsStart } from 'src/core/public';
3130
import { SectionLoading } from '../../../../../../../src/plugins/es_ui_shared/public';
3231
import { ApiKey, ApiKeyToInvalidate } from '../../../../common/model';
@@ -47,10 +46,10 @@ interface State {
4746
isLoadingApp: boolean;
4847
isLoadingTable: boolean;
4948
isAdmin: boolean;
49+
canManage: boolean;
5050
areApiKeysEnabled: boolean;
5151
apiKeys: ApiKey[];
5252
selectedItems: ApiKey[];
53-
permissionDenied: boolean;
5453
error: any;
5554
}
5655

@@ -63,9 +62,9 @@ export class APIKeysGridPage extends Component<Props, State> {
6362
isLoadingApp: true,
6463
isLoadingTable: false,
6564
isAdmin: false,
65+
canManage: false,
6666
areApiKeysEnabled: false,
6767
apiKeys: [],
68-
permissionDenied: false,
6968
selectedItems: [],
7069
error: undefined,
7170
};
@@ -77,19 +76,15 @@ export class APIKeysGridPage extends Component<Props, State> {
7776

7877
public render() {
7978
const {
80-
permissionDenied,
8179
isLoadingApp,
8280
isLoadingTable,
8381
areApiKeysEnabled,
8482
isAdmin,
83+
canManage,
8584
error,
8685
apiKeys,
8786
} = this.state;
8887

89-
if (permissionDenied) {
90-
return <PermissionDenied />;
91-
}
92-
9388
if (isLoadingApp) {
9489
return (
9590
<EuiPageContent>
@@ -103,6 +98,10 @@ export class APIKeysGridPage extends Component<Props, State> {
10398
);
10499
}
105100

101+
if (!canManage) {
102+
return <PermissionDenied />;
103+
}
104+
106105
if (error) {
107106
const {
108107
body: { error: errorTitle, message, statusCode },
@@ -495,26 +494,25 @@ export class APIKeysGridPage extends Component<Props, State> {
495494

496495
private async checkPrivileges() {
497496
try {
498-
const { isAdmin, areApiKeysEnabled } = await this.props.apiKeysAPIClient.checkPrivileges();
499-
this.setState({ isAdmin, areApiKeysEnabled });
497+
const {
498+
isAdmin,
499+
canManage,
500+
areApiKeysEnabled,
501+
} = await this.props.apiKeysAPIClient.checkPrivileges();
502+
this.setState({ isAdmin, canManage, areApiKeysEnabled });
500503

501-
if (areApiKeysEnabled) {
502-
this.initiallyLoadApiKeys();
503-
} else {
504-
// We're done loading and will just show the "Disabled" error.
504+
if (!canManage || !areApiKeysEnabled) {
505505
this.setState({ isLoadingApp: false });
506-
}
507-
} catch (e) {
508-
if (_.get(e, 'body.statusCode') === 403) {
509-
this.setState({ permissionDenied: true, isLoadingApp: false });
510506
} else {
511-
this.props.notifications.toasts.addDanger(
512-
i18n.translate('xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage', {
513-
defaultMessage: 'Error checking privileges: {message}',
514-
values: { message: _.get(e, 'body.message', '') },
515-
})
516-
);
507+
this.initiallyLoadApiKeys();
517508
}
509+
} catch (e) {
510+
this.props.notifications.toasts.addDanger(
511+
i18n.translate('xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage', {
512+
defaultMessage: 'Error checking privileges: {message}',
513+
values: { message: e.body?.message ?? '' },
514+
})
515+
);
518516
}
519517
}
520518

x-pack/plugins/security/public/plugin.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe('Security Plugin', () => {
3737
)
3838
).toEqual({
3939
__legacyCompat: { logoutUrl: '/some-base-path/logout', tenant: '/some-base-path' },
40-
authc: { getCurrentUser: expect.any(Function) },
40+
authc: { getCurrentUser: expect.any(Function), areAPIKeysEnabled: expect.any(Function) },
4141
license: {
4242
isEnabled: expect.any(Function),
4343
getFeatures: expect.any(Function),
@@ -63,7 +63,7 @@ describe('Security Plugin', () => {
6363

6464
expect(setupManagementServiceMock).toHaveBeenCalledTimes(1);
6565
expect(setupManagementServiceMock).toHaveBeenCalledWith({
66-
authc: { getCurrentUser: expect.any(Function) },
66+
authc: { getCurrentUser: expect.any(Function), areAPIKeysEnabled: expect.any(Function) },
6767
license: {
6868
isEnabled: expect.any(Function),
6969
getFeatures: expect.any(Function),

x-pack/plugins/security/server/authentication/api_keys.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,82 @@ describe('API Keys', () => {
4040
});
4141
});
4242

43+
describe('areAPIKeysEnabled()', () => {
44+
it('returns false when security feature is disabled', async () => {
45+
mockLicense.isEnabled.mockReturnValue(false);
46+
47+
const result = await apiKeys.areAPIKeysEnabled();
48+
expect(result).toEqual(false);
49+
expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled();
50+
expect(mockScopedClusterClient.callAsInternalUser).not.toHaveBeenCalled();
51+
expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled();
52+
});
53+
54+
it('returns false when the exception metadata indicates api keys are disabled', async () => {
55+
mockLicense.isEnabled.mockReturnValue(true);
56+
const error = new Error();
57+
(error as any).body = {
58+
error: { 'disabled.feature': 'api_keys' },
59+
};
60+
mockClusterClient.callAsInternalUser.mockRejectedValue(error);
61+
const result = await apiKeys.areAPIKeysEnabled();
62+
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
63+
expect(result).toEqual(false);
64+
});
65+
66+
it('returns true when the operation completes without error', async () => {
67+
mockLicense.isEnabled.mockReturnValue(true);
68+
mockClusterClient.callAsInternalUser.mockResolvedValue({});
69+
const result = await apiKeys.areAPIKeysEnabled();
70+
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
71+
expect(result).toEqual(true);
72+
});
73+
74+
it('throws the original error when exception metadata does not indicate that api keys are disabled', async () => {
75+
mockLicense.isEnabled.mockReturnValue(true);
76+
const error = new Error();
77+
(error as any).body = {
78+
error: { 'disabled.feature': 'something_else' },
79+
};
80+
81+
mockClusterClient.callAsInternalUser.mockRejectedValue(error);
82+
expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error);
83+
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
84+
});
85+
86+
it('throws the original error when exception metadata does not contain `disabled.feature`', async () => {
87+
mockLicense.isEnabled.mockReturnValue(true);
88+
const error = new Error();
89+
(error as any).body = {};
90+
91+
mockClusterClient.callAsInternalUser.mockRejectedValue(error);
92+
expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error);
93+
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
94+
});
95+
96+
it('throws the original error when exception contains no metadata', async () => {
97+
mockLicense.isEnabled.mockReturnValue(true);
98+
const error = new Error();
99+
100+
mockClusterClient.callAsInternalUser.mockRejectedValue(error);
101+
expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error);
102+
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
103+
});
104+
105+
it('calls callCluster with proper parameters', async () => {
106+
mockLicense.isEnabled.mockReturnValue(true);
107+
mockClusterClient.callAsInternalUser.mockResolvedValueOnce({});
108+
109+
const result = await apiKeys.areAPIKeysEnabled();
110+
expect(result).toEqual(true);
111+
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', {
112+
body: {
113+
id: 'kibana-api-key-service-test',
114+
},
115+
});
116+
});
117+
});
118+
43119
describe('create()', () => {
44120
it('returns null when security feature is disabled', async () => {
45121
mockLicense.isEnabled.mockReturnValue(false);

x-pack/plugins/security/server/authentication/api_keys.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,35 @@ export class APIKeys {
125125
this.license = license;
126126
}
127127

128+
/**
129+
* Determines if API Keys are enabled in Elasticsearch.
130+
*/
131+
async areAPIKeysEnabled(): Promise<boolean> {
132+
if (!this.license.isEnabled()) {
133+
return false;
134+
}
135+
136+
const id = `kibana-api-key-service-test`;
137+
138+
this.logger.debug(
139+
`Testing if API Keys are enabled by attempting to invalidate a non-existant key: ${id}`
140+
);
141+
142+
try {
143+
await this.clusterClient.callAsInternalUser('shield.invalidateAPIKey', {
144+
body: {
145+
id,
146+
},
147+
});
148+
return true;
149+
} catch (e) {
150+
if (this.doesErrorIndicateAPIKeysAreDisabled(e)) {
151+
return false;
152+
}
153+
throw e;
154+
}
155+
}
156+
128157
/**
129158
* Tries to create an API key for the current user.
130159
* @param request Request instance.
@@ -247,6 +276,11 @@ export class APIKeys {
247276
return result;
248277
}
249278

279+
private doesErrorIndicateAPIKeysAreDisabled(e: Record<string, any>) {
280+
const disabledFeature = e.body?.error?.['disabled.feature'];
281+
return disabledFeature === 'api_keys';
282+
}
283+
250284
private getGrantParams(authorizationHeader: HTTPAuthorizationHeader): GrantAPIKeyParams {
251285
if (authorizationHeader.scheme.toLowerCase() === 'bearer') {
252286
return {

0 commit comments

Comments
 (0)