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>
This commit is contained in:
Larry Gregory 2020-04-23 15:23:39 -04:00 committed by GitHub
parent a53d53369c
commit 44f9cbcb60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 481 additions and 89 deletions

View file

@ -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 };
}
}

View file

@ -9,5 +9,6 @@ import { AuthenticationServiceSetup } from './authentication_service';
export const authenticationMock = {
createSetup: (): jest.Mocked<AuthenticationServiceSetup> => ({
getCurrentUser: jest.fn(),
areAPIKeysEnabled: jest.fn(),
}),
};

View file

@ -10,7 +10,7 @@ import { AuthenticationServiceSetup } from '../authentication_service';
interface CreateDeps {
application: ApplicationSetup;
authc: AuthenticationServiceSetup;
authc: Pick<AuthenticationServiceSetup, 'getCurrentUser'>;
getStartServices: StartServicesAccessor;
}

View file

@ -14,7 +14,7 @@ import { AuthenticationStatePage } from '../components';
interface Props {
basePath: IBasePath;
authc: AuthenticationServiceSetup;
authc: Pick<AuthenticationServiceSetup, 'getCurrentUser'>;
}
export function OverwrittenSessionPage({ authc, basePath }: Props) {

View file

@ -10,6 +10,7 @@ import { ApiKey, ApiKeyToInvalidate } from '../../../common/model';
interface CheckPrivilegesResponse {
areApiKeysEnabled: boolean;
isAdmin: boolean;
canManage: boolean;
}
interface InvalidateApiKeysResponse {

View file

@ -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,
});

View file

@ -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 ?? '' },
})
);
}
}

View file

@ -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),

View file

@ -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);

View file

@ -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 {

View file

@ -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(),

View file

@ -172,6 +172,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),

View file

@ -69,6 +69,7 @@ describe('Security Plugin', () => {
"registerPrivilegesWithCluster": [Function],
},
"authc": Object {
"areAPIKeysEnabled": [Function],
"createAPIKey": [Function],
"getCurrentUser": [Function],
"getSessionInfo": [Function],

View 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,
},
},
});
});
});

View 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));
}
})
);
}

View file

@ -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);

View file

@ -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 },
},
});
});

View file

@ -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));

View 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 });
});
});
});
});
}

View file

@ -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'));

View file

@ -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'));

View file

@ -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;