mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
* 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> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
2408d9d558
commit
9024d28798
22 changed files with 481 additions and 89 deletions
|
@ -25,6 +25,11 @@ export interface AuthenticationServiceSetup {
|
|||
* Returns currently authenticated user and throws if current user isn't authenticated.
|
||||
*/
|
||||
getCurrentUser: () => Promise<AuthenticatedUser>;
|
||||
|
||||
/**
|
||||
* Determines if API Keys are currently enabled.
|
||||
*/
|
||||
areAPIKeysEnabled: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export class AuthenticationService {
|
||||
|
@ -37,11 +42,15 @@ export class AuthenticationService {
|
|||
const getCurrentUser = async () =>
|
||||
(await http.get('/internal/security/me', { asSystemRequest: true })) as AuthenticatedUser;
|
||||
|
||||
const areAPIKeysEnabled = async () =>
|
||||
((await http.get('/internal/security/api_key/_enabled')) as { apiKeysEnabled: boolean })
|
||||
.apiKeysEnabled;
|
||||
|
||||
loginApp.create({ application, config, getStartServices, http });
|
||||
logoutApp.create({ application, http });
|
||||
loggedOutApp.create({ application, getStartServices, http });
|
||||
overwrittenSessionApp.create({ application, authc: { getCurrentUser }, getStartServices });
|
||||
|
||||
return { getCurrentUser };
|
||||
return { getCurrentUser, areAPIKeysEnabled };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,5 +9,6 @@ import { AuthenticationServiceSetup } from './authentication_service';
|
|||
export const authenticationMock = {
|
||||
createSetup: (): jest.Mocked<AuthenticationServiceSetup> => ({
|
||||
getCurrentUser: jest.fn(),
|
||||
areAPIKeysEnabled: jest.fn(),
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -10,7 +10,7 @@ import { AuthenticationServiceSetup } from '../authentication_service';
|
|||
|
||||
interface CreateDeps {
|
||||
application: ApplicationSetup;
|
||||
authc: AuthenticationServiceSetup;
|
||||
authc: Pick<AuthenticationServiceSetup, 'getCurrentUser'>;
|
||||
getStartServices: StartServicesAccessor;
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ import { AuthenticationStatePage } from '../components';
|
|||
|
||||
interface Props {
|
||||
basePath: IBasePath;
|
||||
authc: AuthenticationServiceSetup;
|
||||
authc: Pick<AuthenticationServiceSetup, 'getCurrentUser'>;
|
||||
}
|
||||
|
||||
export function OverwrittenSessionPage({ authc, basePath }: Props) {
|
||||
|
|
|
@ -10,6 +10,7 @@ import { ApiKey, ApiKeyToInvalidate } from '../../../common/model';
|
|||
interface CheckPrivilegesResponse {
|
||||
areApiKeysEnabled: boolean;
|
||||
isAdmin: boolean;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
interface InvalidateApiKeysResponse {
|
||||
|
|
|
@ -18,7 +18,6 @@ import { APIKeysGridPage } from './api_keys_grid_page';
|
|||
import { coreMock } from '../../../../../../../src/core/public/mocks';
|
||||
import { apiKeysAPIClientMock } from '../index.mock';
|
||||
|
||||
const mock403 = () => ({ body: { statusCode: 403 } });
|
||||
const mock500 = () => ({ body: { error: 'Internal Server Error', message: '', statusCode: 500 } });
|
||||
|
||||
const waitForRender = async (
|
||||
|
@ -48,6 +47,7 @@ describe('APIKeysGridPage', () => {
|
|||
apiClientMock.checkPrivileges.mockResolvedValue({
|
||||
isAdmin: true,
|
||||
areApiKeysEnabled: true,
|
||||
canManage: true,
|
||||
});
|
||||
apiClientMock.getApiKeys.mockResolvedValue({
|
||||
apiKeys: [
|
||||
|
@ -82,6 +82,7 @@ describe('APIKeysGridPage', () => {
|
|||
it('renders a callout when API keys are not enabled', async () => {
|
||||
apiClientMock.checkPrivileges.mockResolvedValue({
|
||||
isAdmin: true,
|
||||
canManage: true,
|
||||
areApiKeysEnabled: false,
|
||||
});
|
||||
|
||||
|
@ -95,7 +96,11 @@ describe('APIKeysGridPage', () => {
|
|||
});
|
||||
|
||||
it('renders permission denied if user does not have required permissions', async () => {
|
||||
apiClientMock.checkPrivileges.mockRejectedValue(mock403());
|
||||
apiClientMock.checkPrivileges.mockResolvedValue({
|
||||
canManage: false,
|
||||
isAdmin: false,
|
||||
areApiKeysEnabled: true,
|
||||
});
|
||||
|
||||
const wrapper = mountWithIntl(<APIKeysGridPage {...getViewProperties()} />);
|
||||
|
||||
|
@ -152,6 +157,7 @@ describe('APIKeysGridPage', () => {
|
|||
beforeEach(() => {
|
||||
apiClientMock.checkPrivileges.mockResolvedValue({
|
||||
isAdmin: false,
|
||||
canManage: true,
|
||||
areApiKeysEnabled: true,
|
||||
});
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@ import {
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import moment from 'moment-timezone';
|
||||
import _ from 'lodash';
|
||||
import { NotificationsStart } from 'src/core/public';
|
||||
import { SectionLoading } from '../../../../../../../src/plugins/es_ui_shared/public';
|
||||
import { ApiKey, ApiKeyToInvalidate } from '../../../../common/model';
|
||||
|
@ -47,10 +46,10 @@ interface State {
|
|||
isLoadingApp: boolean;
|
||||
isLoadingTable: boolean;
|
||||
isAdmin: boolean;
|
||||
canManage: boolean;
|
||||
areApiKeysEnabled: boolean;
|
||||
apiKeys: ApiKey[];
|
||||
selectedItems: ApiKey[];
|
||||
permissionDenied: boolean;
|
||||
error: any;
|
||||
}
|
||||
|
||||
|
@ -63,9 +62,9 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
isLoadingApp: true,
|
||||
isLoadingTable: false,
|
||||
isAdmin: false,
|
||||
canManage: false,
|
||||
areApiKeysEnabled: false,
|
||||
apiKeys: [],
|
||||
permissionDenied: false,
|
||||
selectedItems: [],
|
||||
error: undefined,
|
||||
};
|
||||
|
@ -77,19 +76,15 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
|
||||
public render() {
|
||||
const {
|
||||
permissionDenied,
|
||||
isLoadingApp,
|
||||
isLoadingTable,
|
||||
areApiKeysEnabled,
|
||||
isAdmin,
|
||||
canManage,
|
||||
error,
|
||||
apiKeys,
|
||||
} = this.state;
|
||||
|
||||
if (permissionDenied) {
|
||||
return <PermissionDenied />;
|
||||
}
|
||||
|
||||
if (isLoadingApp) {
|
||||
return (
|
||||
<EuiPageContent>
|
||||
|
@ -103,6 +98,10 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
return <PermissionDenied />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const {
|
||||
body: { error: errorTitle, message, statusCode },
|
||||
|
@ -495,26 +494,25 @@ export class APIKeysGridPage extends Component<Props, State> {
|
|||
|
||||
private async checkPrivileges() {
|
||||
try {
|
||||
const { isAdmin, areApiKeysEnabled } = await this.props.apiKeysAPIClient.checkPrivileges();
|
||||
this.setState({ isAdmin, areApiKeysEnabled });
|
||||
const {
|
||||
isAdmin,
|
||||
canManage,
|
||||
areApiKeysEnabled,
|
||||
} = await this.props.apiKeysAPIClient.checkPrivileges();
|
||||
this.setState({ isAdmin, canManage, areApiKeysEnabled });
|
||||
|
||||
if (areApiKeysEnabled) {
|
||||
this.initiallyLoadApiKeys();
|
||||
} else {
|
||||
// We're done loading and will just show the "Disabled" error.
|
||||
if (!canManage || !areApiKeysEnabled) {
|
||||
this.setState({ isLoadingApp: false });
|
||||
} else {
|
||||
this.initiallyLoadApiKeys();
|
||||
}
|
||||
} catch (e) {
|
||||
if (_.get(e, 'body.statusCode') === 403) {
|
||||
this.setState({ permissionDenied: true, isLoadingApp: false });
|
||||
} else {
|
||||
this.props.notifications.toasts.addDanger(
|
||||
i18n.translate('xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage', {
|
||||
defaultMessage: 'Error checking privileges: {message}',
|
||||
values: { message: _.get(e, 'body.message', '') },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.props.notifications.toasts.addDanger(
|
||||
i18n.translate('xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage', {
|
||||
defaultMessage: 'Error checking privileges: {message}',
|
||||
values: { message: e.body?.message ?? '' },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,7 +37,7 @@ describe('Security Plugin', () => {
|
|||
)
|
||||
).toEqual({
|
||||
__legacyCompat: { logoutUrl: '/some-base-path/logout', tenant: '/some-base-path' },
|
||||
authc: { getCurrentUser: expect.any(Function) },
|
||||
authc: { getCurrentUser: expect.any(Function), areAPIKeysEnabled: expect.any(Function) },
|
||||
license: {
|
||||
isEnabled: expect.any(Function),
|
||||
getFeatures: expect.any(Function),
|
||||
|
@ -63,7 +63,7 @@ describe('Security Plugin', () => {
|
|||
|
||||
expect(setupManagementServiceMock).toHaveBeenCalledTimes(1);
|
||||
expect(setupManagementServiceMock).toHaveBeenCalledWith({
|
||||
authc: { getCurrentUser: expect.any(Function) },
|
||||
authc: { getCurrentUser: expect.any(Function), areAPIKeysEnabled: expect.any(Function) },
|
||||
license: {
|
||||
isEnabled: expect.any(Function),
|
||||
getFeatures: expect.any(Function),
|
||||
|
|
|
@ -40,6 +40,82 @@ describe('API Keys', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('areAPIKeysEnabled()', () => {
|
||||
it('returns false when security feature is disabled', async () => {
|
||||
mockLicense.isEnabled.mockReturnValue(false);
|
||||
|
||||
const result = await apiKeys.areAPIKeysEnabled();
|
||||
expect(result).toEqual(false);
|
||||
expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled();
|
||||
expect(mockScopedClusterClient.callAsInternalUser).not.toHaveBeenCalled();
|
||||
expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when the exception metadata indicates api keys are disabled', async () => {
|
||||
mockLicense.isEnabled.mockReturnValue(true);
|
||||
const error = new Error();
|
||||
(error as any).body = {
|
||||
error: { 'disabled.feature': 'api_keys' },
|
||||
};
|
||||
mockClusterClient.callAsInternalUser.mockRejectedValue(error);
|
||||
const result = await apiKeys.areAPIKeysEnabled();
|
||||
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when the operation completes without error', async () => {
|
||||
mockLicense.isEnabled.mockReturnValue(true);
|
||||
mockClusterClient.callAsInternalUser.mockResolvedValue({});
|
||||
const result = await apiKeys.areAPIKeysEnabled();
|
||||
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it('throws the original error when exception metadata does not indicate that api keys are disabled', async () => {
|
||||
mockLicense.isEnabled.mockReturnValue(true);
|
||||
const error = new Error();
|
||||
(error as any).body = {
|
||||
error: { 'disabled.feature': 'something_else' },
|
||||
};
|
||||
|
||||
mockClusterClient.callAsInternalUser.mockRejectedValue(error);
|
||||
expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error);
|
||||
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws the original error when exception metadata does not contain `disabled.feature`', async () => {
|
||||
mockLicense.isEnabled.mockReturnValue(true);
|
||||
const error = new Error();
|
||||
(error as any).body = {};
|
||||
|
||||
mockClusterClient.callAsInternalUser.mockRejectedValue(error);
|
||||
expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error);
|
||||
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws the original error when exception contains no metadata', async () => {
|
||||
mockLicense.isEnabled.mockReturnValue(true);
|
||||
const error = new Error();
|
||||
|
||||
mockClusterClient.callAsInternalUser.mockRejectedValue(error);
|
||||
expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error);
|
||||
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls callCluster with proper parameters', async () => {
|
||||
mockLicense.isEnabled.mockReturnValue(true);
|
||||
mockClusterClient.callAsInternalUser.mockResolvedValueOnce({});
|
||||
|
||||
const result = await apiKeys.areAPIKeysEnabled();
|
||||
expect(result).toEqual(true);
|
||||
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', {
|
||||
body: {
|
||||
id: 'kibana-api-key-service-test',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create()', () => {
|
||||
it('returns null when security feature is disabled', async () => {
|
||||
mockLicense.isEnabled.mockReturnValue(false);
|
||||
|
|
|
@ -125,6 +125,35 @@ export class APIKeys {
|
|||
this.license = license;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if API Keys are enabled in Elasticsearch.
|
||||
*/
|
||||
async areAPIKeysEnabled(): Promise<boolean> {
|
||||
if (!this.license.isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const id = `kibana-api-key-service-test`;
|
||||
|
||||
this.logger.debug(
|
||||
`Testing if API Keys are enabled by attempting to invalidate a non-existant key: ${id}`
|
||||
);
|
||||
|
||||
try {
|
||||
await this.clusterClient.callAsInternalUser('shield.invalidateAPIKey', {
|
||||
body: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (this.doesErrorIndicateAPIKeysAreDisabled(e)) {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to create an API key for the current user.
|
||||
* @param request Request instance.
|
||||
|
@ -247,6 +276,11 @@ export class APIKeys {
|
|||
return result;
|
||||
}
|
||||
|
||||
private doesErrorIndicateAPIKeysAreDisabled(e: Record<string, any>) {
|
||||
const disabledFeature = e.body?.error?.['disabled.feature'];
|
||||
return disabledFeature === 'api_keys';
|
||||
}
|
||||
|
||||
private getGrantParams(authorizationHeader: HTTPAuthorizationHeader): GrantAPIKeyParams {
|
||||
if (authorizationHeader.scheme.toLowerCase() === 'bearer') {
|
||||
return {
|
||||
|
|
|
@ -11,6 +11,7 @@ export const authenticationMock = {
|
|||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
isProviderTypeEnabled: jest.fn(),
|
||||
areAPIKeysEnabled: jest.fn(),
|
||||
createAPIKey: jest.fn(),
|
||||
getCurrentUser: jest.fn(),
|
||||
grantAPIKeyAsInternalUser: jest.fn(),
|
||||
|
|
|
@ -184,6 +184,7 @@ export async function setupAuthentication({
|
|||
getSessionInfo: authenticator.getSessionInfo.bind(authenticator),
|
||||
isProviderTypeEnabled: authenticator.isProviderTypeEnabled.bind(authenticator),
|
||||
getCurrentUser,
|
||||
areAPIKeysEnabled: () => apiKeys.areAPIKeysEnabled(),
|
||||
createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) =>
|
||||
apiKeys.create(request, params),
|
||||
grantAPIKeyAsInternalUser: (request: KibanaRequest) => apiKeys.grantAsInternalUser(request),
|
||||
|
|
|
@ -69,6 +69,7 @@ describe('Security Plugin', () => {
|
|||
"registerPrivilegesWithCluster": [Function],
|
||||
},
|
||||
"authc": Object {
|
||||
"areAPIKeysEnabled": [Function],
|
||||
"createAPIKey": [Function],
|
||||
"getCurrentUser": [Function],
|
||||
"getSessionInfo": [Function],
|
||||
|
|
118
x-pack/plugins/security/server/routes/api_keys/enabled.test.ts
Normal file
118
x-pack/plugins/security/server/routes/api_keys/enabled.test.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server';
|
||||
import { LicenseCheck } from '../../../../licensing/server';
|
||||
|
||||
import { httpServerMock } from '../../../../../../src/core/server/mocks';
|
||||
import { routeDefinitionParamsMock } from '../index.mock';
|
||||
import Boom from 'boom';
|
||||
import { defineEnabledApiKeysRoutes } from './enabled';
|
||||
import { APIKeys } from '../../authentication/api_keys';
|
||||
|
||||
interface TestOptions {
|
||||
licenseCheckResult?: LicenseCheck;
|
||||
apiResponse?: () => Promise<unknown>;
|
||||
asserts: { statusCode: number; result?: Record<string, any> };
|
||||
}
|
||||
|
||||
describe('API keys enabled', () => {
|
||||
const enabledApiKeysTest = (
|
||||
description: string,
|
||||
{ licenseCheckResult = { state: 'valid' }, apiResponse, asserts }: TestOptions
|
||||
) => {
|
||||
test(description, async () => {
|
||||
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
|
||||
|
||||
const apiKeys = new APIKeys({
|
||||
logger: mockRouteDefinitionParams.logger,
|
||||
clusterClient: mockRouteDefinitionParams.clusterClient,
|
||||
license: mockRouteDefinitionParams.license,
|
||||
});
|
||||
|
||||
mockRouteDefinitionParams.authc.areAPIKeysEnabled.mockImplementation(() =>
|
||||
apiKeys.areAPIKeysEnabled()
|
||||
);
|
||||
|
||||
if (apiResponse) {
|
||||
mockRouteDefinitionParams.clusterClient.callAsInternalUser.mockImplementation(apiResponse);
|
||||
}
|
||||
|
||||
defineEnabledApiKeysRoutes(mockRouteDefinitionParams);
|
||||
const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls;
|
||||
|
||||
const headers = { authorization: 'foo' };
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
method: 'get',
|
||||
path: '/internal/security/api_key/_enabled',
|
||||
headers,
|
||||
});
|
||||
const mockContext = ({
|
||||
licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } },
|
||||
} as unknown) as RequestHandlerContext;
|
||||
|
||||
const response = await handler(mockContext, mockRequest, kibanaResponseFactory);
|
||||
expect(response.status).toBe(asserts.statusCode);
|
||||
expect(response.payload).toEqual(asserts.result);
|
||||
|
||||
if (apiResponse) {
|
||||
expect(mockRouteDefinitionParams.clusterClient.callAsInternalUser).toHaveBeenCalledWith(
|
||||
'shield.invalidateAPIKey',
|
||||
{
|
||||
body: {
|
||||
id: expect.any(String),
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic');
|
||||
});
|
||||
};
|
||||
|
||||
describe('failure', () => {
|
||||
enabledApiKeysTest('returns result of license checker', {
|
||||
licenseCheckResult: { state: 'invalid', message: 'test forbidden message' },
|
||||
asserts: { statusCode: 403, result: { message: 'test forbidden message' } },
|
||||
});
|
||||
|
||||
const error = Boom.notAcceptable('test not acceptable message');
|
||||
enabledApiKeysTest('returns error from cluster client', {
|
||||
apiResponse: async () => {
|
||||
throw error;
|
||||
},
|
||||
asserts: { statusCode: 406, result: error },
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
enabledApiKeysTest('returns true if API Keys are enabled', {
|
||||
apiResponse: async () => ({}),
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
apiKeysEnabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
enabledApiKeysTest('returns false if API Keys are disabled', {
|
||||
apiResponse: async () => {
|
||||
const error = new Error();
|
||||
(error as any).body = {
|
||||
error: { 'disabled.feature': 'api_keys' },
|
||||
};
|
||||
throw error;
|
||||
},
|
||||
asserts: {
|
||||
statusCode: 200,
|
||||
result: {
|
||||
apiKeysEnabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
27
x-pack/plugins/security/server/routes/api_keys/enabled.ts
Normal file
27
x-pack/plugins/security/server/routes/api_keys/enabled.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { wrapIntoCustomErrorResponse } from '../../errors';
|
||||
import { createLicensedRouteHandler } from '../licensed_route_handler';
|
||||
import { RouteDefinitionParams } from '..';
|
||||
|
||||
export function defineEnabledApiKeysRoutes({ router, authc }: RouteDefinitionParams) {
|
||||
router.get(
|
||||
{
|
||||
path: '/internal/security/api_key/_enabled',
|
||||
validate: false,
|
||||
},
|
||||
createLicensedRouteHandler(async (context, request, response) => {
|
||||
try {
|
||||
const apiKeysEnabled = await authc.areAPIKeysEnabled();
|
||||
|
||||
return response.ok({ body: { apiKeysEnabled } });
|
||||
} catch (error) {
|
||||
return response.customError(wrapIntoCustomErrorResponse(error));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -7,9 +7,11 @@
|
|||
import { defineGetApiKeysRoutes } from './get';
|
||||
import { defineCheckPrivilegesRoutes } from './privileges';
|
||||
import { defineInvalidateApiKeysRoutes } from './invalidate';
|
||||
import { defineEnabledApiKeysRoutes } from './enabled';
|
||||
import { RouteDefinitionParams } from '..';
|
||||
|
||||
export function defineApiKeysRoutes(params: RouteDefinitionParams) {
|
||||
defineEnabledApiKeysRoutes(params);
|
||||
defineGetApiKeysRoutes(params);
|
||||
defineCheckPrivilegesRoutes(params);
|
||||
defineInvalidateApiKeysRoutes(params);
|
||||
|
|
|
@ -11,25 +11,53 @@ import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../
|
|||
import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks';
|
||||
import { routeDefinitionParamsMock } from '../index.mock';
|
||||
import { defineCheckPrivilegesRoutes } from './privileges';
|
||||
import { APIKeys } from '../../authentication/api_keys';
|
||||
|
||||
interface TestOptions {
|
||||
licenseCheckResult?: LicenseCheck;
|
||||
apiResponses?: Array<() => Promise<unknown>>;
|
||||
asserts: { statusCode: number; result?: Record<string, any>; apiArguments?: unknown[][] };
|
||||
callAsInternalUserResponses?: Array<() => Promise<unknown>>;
|
||||
callAsCurrentUserResponses?: Array<() => Promise<unknown>>;
|
||||
asserts: {
|
||||
statusCode: number;
|
||||
result?: Record<string, any>;
|
||||
callAsInternalUserAPIArguments?: unknown[][];
|
||||
callAsCurrentUserAPIArguments?: unknown[][];
|
||||
};
|
||||
}
|
||||
|
||||
describe('Check API keys privileges', () => {
|
||||
const getPrivilegesTest = (
|
||||
description: string,
|
||||
{ licenseCheckResult = { state: 'valid' }, apiResponses = [], asserts }: TestOptions
|
||||
{
|
||||
licenseCheckResult = { state: 'valid' },
|
||||
callAsInternalUserResponses = [],
|
||||
callAsCurrentUserResponses = [],
|
||||
asserts,
|
||||
}: TestOptions
|
||||
) => {
|
||||
test(description, async () => {
|
||||
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
|
||||
|
||||
const apiKeys = new APIKeys({
|
||||
logger: mockRouteDefinitionParams.logger,
|
||||
clusterClient: mockRouteDefinitionParams.clusterClient,
|
||||
license: mockRouteDefinitionParams.license,
|
||||
});
|
||||
|
||||
mockRouteDefinitionParams.authc.areAPIKeysEnabled.mockImplementation(() =>
|
||||
apiKeys.areAPIKeysEnabled()
|
||||
);
|
||||
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
|
||||
mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
for (const apiResponse of apiResponses) {
|
||||
for (const apiResponse of callAsCurrentUserResponses) {
|
||||
mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(apiResponse);
|
||||
}
|
||||
for (const apiResponse of callAsInternalUserResponses) {
|
||||
mockRouteDefinitionParams.clusterClient.callAsInternalUser.mockImplementationOnce(
|
||||
apiResponse
|
||||
);
|
||||
}
|
||||
|
||||
defineCheckPrivilegesRoutes(mockRouteDefinitionParams);
|
||||
const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls;
|
||||
|
@ -48,8 +76,8 @@ describe('Check API keys privileges', () => {
|
|||
expect(response.status).toBe(asserts.statusCode);
|
||||
expect(response.payload).toEqual(asserts.result);
|
||||
|
||||
if (Array.isArray(asserts.apiArguments)) {
|
||||
for (const apiArguments of asserts.apiArguments) {
|
||||
if (Array.isArray(asserts.callAsCurrentUserAPIArguments)) {
|
||||
for (const apiArguments of asserts.callAsCurrentUserAPIArguments) {
|
||||
expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(
|
||||
mockRequest
|
||||
);
|
||||
|
@ -58,6 +86,17 @@ describe('Check API keys privileges', () => {
|
|||
} else {
|
||||
expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
if (Array.isArray(asserts.callAsInternalUserAPIArguments)) {
|
||||
for (const apiArguments of asserts.callAsInternalUserAPIArguments) {
|
||||
expect(mockRouteDefinitionParams.clusterClient.callAsInternalUser).toHaveBeenCalledWith(
|
||||
...apiArguments
|
||||
);
|
||||
}
|
||||
} else {
|
||||
expect(mockRouteDefinitionParams.clusterClient.callAsInternalUser).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic');
|
||||
});
|
||||
};
|
||||
|
@ -70,16 +109,21 @@ describe('Check API keys privileges', () => {
|
|||
|
||||
const error = Boom.notAcceptable('test not acceptable message');
|
||||
getPrivilegesTest('returns error from cluster client', {
|
||||
apiResponses: [
|
||||
callAsCurrentUserResponses: [
|
||||
async () => {
|
||||
throw error;
|
||||
},
|
||||
async () => {},
|
||||
],
|
||||
callAsInternalUserResponses: [async () => {}],
|
||||
asserts: {
|
||||
apiArguments: [
|
||||
['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }],
|
||||
['shield.getAPIKeys', { owner: true }],
|
||||
callAsCurrentUserAPIArguments: [
|
||||
[
|
||||
'shield.hasPrivileges',
|
||||
{ body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] } },
|
||||
],
|
||||
],
|
||||
callAsInternalUserAPIArguments: [
|
||||
['shield.invalidateAPIKey', { body: { id: expect.any(String) } }],
|
||||
],
|
||||
statusCode: 406,
|
||||
result: error,
|
||||
|
@ -89,14 +133,16 @@ describe('Check API keys privileges', () => {
|
|||
|
||||
describe('success', () => {
|
||||
getPrivilegesTest('returns areApiKeysEnabled and isAdmin', {
|
||||
apiResponses: [
|
||||
callAsCurrentUserResponses: [
|
||||
async () => ({
|
||||
username: 'elastic',
|
||||
has_all_requested: true,
|
||||
cluster: { manage_api_key: true, manage_security: true },
|
||||
cluster: { manage_api_key: true, manage_security: true, manage_own_api_key: false },
|
||||
index: {},
|
||||
application: {},
|
||||
}),
|
||||
],
|
||||
callAsInternalUserResponses: [
|
||||
async () => ({
|
||||
api_keys: [
|
||||
{
|
||||
|
@ -112,71 +158,108 @@ describe('Check API keys privileges', () => {
|
|||
}),
|
||||
],
|
||||
asserts: {
|
||||
apiArguments: [
|
||||
['shield.getAPIKeys', { owner: true }],
|
||||
['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }],
|
||||
callAsCurrentUserAPIArguments: [
|
||||
[
|
||||
'shield.hasPrivileges',
|
||||
{ body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] } },
|
||||
],
|
||||
],
|
||||
callAsInternalUserAPIArguments: [
|
||||
['shield.invalidateAPIKey', { body: { id: expect.any(String) } }],
|
||||
],
|
||||
statusCode: 200,
|
||||
result: { areApiKeysEnabled: true, isAdmin: true },
|
||||
result: { areApiKeysEnabled: true, isAdmin: true, canManage: true },
|
||||
},
|
||||
});
|
||||
|
||||
getPrivilegesTest(
|
||||
'returns areApiKeysEnabled=false when getAPIKeys error message includes "api keys are not enabled"',
|
||||
'returns areApiKeysEnabled=false when API Keys are disabled in Elasticsearch',
|
||||
{
|
||||
apiResponses: [
|
||||
callAsCurrentUserResponses: [
|
||||
async () => ({
|
||||
username: 'elastic',
|
||||
has_all_requested: true,
|
||||
cluster: { manage_api_key: true, manage_security: true },
|
||||
cluster: { manage_api_key: true, manage_security: true, manage_own_api_key: true },
|
||||
index: {},
|
||||
application: {},
|
||||
}),
|
||||
],
|
||||
callAsInternalUserResponses: [
|
||||
async () => {
|
||||
throw Boom.unauthorized('api keys are not enabled');
|
||||
const error = new Error();
|
||||
(error as any).body = {
|
||||
error: {
|
||||
'disabled.feature': 'api_keys',
|
||||
},
|
||||
};
|
||||
throw error;
|
||||
},
|
||||
],
|
||||
asserts: {
|
||||
apiArguments: [
|
||||
['shield.getAPIKeys', { owner: true }],
|
||||
['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }],
|
||||
callAsCurrentUserAPIArguments: [
|
||||
[
|
||||
'shield.hasPrivileges',
|
||||
{ body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] } },
|
||||
],
|
||||
],
|
||||
callAsInternalUserAPIArguments: [
|
||||
['shield.invalidateAPIKey', { body: { id: expect.any(String) } }],
|
||||
],
|
||||
statusCode: 200,
|
||||
result: { areApiKeysEnabled: false, isAdmin: true },
|
||||
result: { areApiKeysEnabled: false, isAdmin: true, canManage: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
getPrivilegesTest('returns isAdmin=false when user has insufficient privileges', {
|
||||
apiResponses: [
|
||||
callAsCurrentUserResponses: [
|
||||
async () => ({
|
||||
username: 'elastic',
|
||||
has_all_requested: true,
|
||||
cluster: { manage_api_key: false, manage_security: false },
|
||||
cluster: { manage_api_key: false, manage_security: false, manage_own_api_key: false },
|
||||
index: {},
|
||||
application: {},
|
||||
}),
|
||||
async () => ({
|
||||
api_keys: [
|
||||
{
|
||||
id: 'si8If24B1bKsmSLTAhJV',
|
||||
name: 'my-api-key',
|
||||
creation: 1574089261632,
|
||||
expiration: 1574175661632,
|
||||
invalidated: false,
|
||||
username: 'elastic',
|
||||
realm: 'reserved',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
callAsInternalUserResponses: [async () => ({})],
|
||||
asserts: {
|
||||
apiArguments: [
|
||||
['shield.getAPIKeys', { owner: true }],
|
||||
['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }],
|
||||
callAsCurrentUserAPIArguments: [
|
||||
[
|
||||
'shield.hasPrivileges',
|
||||
{ body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] } },
|
||||
],
|
||||
],
|
||||
callAsInternalUserAPIArguments: [
|
||||
['shield.invalidateAPIKey', { body: { id: expect.any(String) } }],
|
||||
],
|
||||
statusCode: 200,
|
||||
result: { areApiKeysEnabled: true, isAdmin: false },
|
||||
result: { areApiKeysEnabled: true, isAdmin: false, canManage: false },
|
||||
},
|
||||
});
|
||||
|
||||
getPrivilegesTest('returns canManage=true when user can manage their own API Keys', {
|
||||
callAsCurrentUserResponses: [
|
||||
async () => ({
|
||||
username: 'elastic',
|
||||
has_all_requested: true,
|
||||
cluster: { manage_api_key: false, manage_security: false, manage_own_api_key: true },
|
||||
index: {},
|
||||
application: {},
|
||||
}),
|
||||
],
|
||||
callAsInternalUserResponses: [async () => ({})],
|
||||
asserts: {
|
||||
callAsCurrentUserAPIArguments: [
|
||||
[
|
||||
'shield.hasPrivileges',
|
||||
{ body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] } },
|
||||
],
|
||||
],
|
||||
callAsInternalUserAPIArguments: [
|
||||
['shield.invalidateAPIKey', { body: { id: expect.any(String) } }],
|
||||
],
|
||||
statusCode: 200,
|
||||
result: { areApiKeysEnabled: true, isAdmin: false, canManage: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,7 +8,11 @@ import { wrapIntoCustomErrorResponse } from '../../errors';
|
|||
import { createLicensedRouteHandler } from '../licensed_route_handler';
|
||||
import { RouteDefinitionParams } from '..';
|
||||
|
||||
export function defineCheckPrivilegesRoutes({ router, clusterClient }: RouteDefinitionParams) {
|
||||
export function defineCheckPrivilegesRoutes({
|
||||
router,
|
||||
clusterClient,
|
||||
authc,
|
||||
}: RouteDefinitionParams) {
|
||||
router.get(
|
||||
{
|
||||
path: '/internal/security/api_key/privileges',
|
||||
|
@ -20,26 +24,25 @@ export function defineCheckPrivilegesRoutes({ router, clusterClient }: RouteDefi
|
|||
|
||||
const [
|
||||
{
|
||||
cluster: { manage_security: manageSecurity, manage_api_key: manageApiKey },
|
||||
cluster: {
|
||||
manage_security: manageSecurity,
|
||||
manage_api_key: manageApiKey,
|
||||
manage_own_api_key: manageOwnApiKey,
|
||||
},
|
||||
},
|
||||
{ areApiKeysEnabled },
|
||||
areApiKeysEnabled,
|
||||
] = await Promise.all([
|
||||
scopedClusterClient.callAsCurrentUser('shield.hasPrivileges', {
|
||||
body: { cluster: ['manage_security', 'manage_api_key'] },
|
||||
body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] },
|
||||
}),
|
||||
scopedClusterClient.callAsCurrentUser('shield.getAPIKeys', { owner: true }).then(
|
||||
// If the API returns a truthy result that means it's enabled.
|
||||
result => ({ areApiKeysEnabled: !!result }),
|
||||
// This is a brittle dependency upon message. Tracked by https://github.com/elastic/elasticsearch/issues/47759.
|
||||
e =>
|
||||
e.message.includes('api keys are not enabled')
|
||||
? Promise.resolve({ areApiKeysEnabled: false })
|
||||
: Promise.reject(e)
|
||||
),
|
||||
authc.areAPIKeysEnabled(),
|
||||
]);
|
||||
|
||||
const isAdmin = manageSecurity || manageApiKey;
|
||||
const canManage = manageSecurity || manageApiKey || manageOwnApiKey;
|
||||
|
||||
return response.ok({
|
||||
body: { areApiKeysEnabled, isAdmin: manageSecurity || manageApiKey },
|
||||
body: { areApiKeysEnabled, isAdmin, canManage },
|
||||
});
|
||||
} catch (error) {
|
||||
return response.customError(wrapIntoCustomErrorResponse(error));
|
||||
|
|
28
x-pack/test/api_integration/apis/security/api_keys.ts
Normal file
28
x-pack/test/api_integration/apis/security/api_keys.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect/expect.js';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('API Keys', () => {
|
||||
describe('GET /internal/security/api_key/_enabled', () => {
|
||||
it('should indicate that API Keys are enabled', async () => {
|
||||
await supertest
|
||||
.get('/internal/security/api_key/_enabled')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send()
|
||||
.expect(200)
|
||||
.then((response: Record<string, any>) => {
|
||||
const payload = response.body;
|
||||
expect(payload).to.eql({ apiKeysEnabled: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -11,6 +11,7 @@ export default function({ loadTestFile }) {
|
|||
// Updates here should be mirrored in `./security_basic.ts` if tests
|
||||
// should also run under a basic license.
|
||||
|
||||
loadTestFile(require.resolve('./api_keys'));
|
||||
loadTestFile(require.resolve('./basic_login'));
|
||||
loadTestFile(require.resolve('./builtin_es_privileges'));
|
||||
loadTestFile(require.resolve('./change_password'));
|
||||
|
|
|
@ -13,6 +13,7 @@ export default function({ loadTestFile }: FtrProviderContext) {
|
|||
// Updates here should be mirrored in `./index.js` if tests
|
||||
// should also run under a trial/platinum license.
|
||||
|
||||
loadTestFile(require.resolve('./api_keys'));
|
||||
loadTestFile(require.resolve('./basic_login'));
|
||||
loadTestFile(require.resolve('./builtin_es_privileges'));
|
||||
loadTestFile(require.resolve('./change_password'));
|
||||
|
|
|
@ -13,6 +13,7 @@ export default async function({ readConfigFile }) {
|
|||
config.esTestCluster.serverArgs = [
|
||||
'xpack.license.self_generated.type=basic',
|
||||
'xpack.security.enabled=true',
|
||||
'xpack.security.authc.api_key.enabled=true',
|
||||
];
|
||||
config.testFiles = [require.resolve('./apis/security/security_basic')];
|
||||
return config;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue