Add createApiKey support to security plugin (#42146)

* Add createApiKey support to security plugin

* Expiration is optional

* Start moving code to new platform

* Add unit tests

* Fix jest test

* Apply PR feedback

* Apply PR feedback

* Apply PR feedback pt2
This commit is contained in:
Mike Côté 2019-07-31 14:41:31 -04:00 committed by GitHub
parent 33fea2f391
commit 011c04f9ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 189 additions and 0 deletions

View file

@ -497,5 +497,24 @@
}
]
});
/**
* Creates an API key in Elasticsearch for the current user.
*
* @param {string} name A name for this API key
* @param {object} role_descriptors Role descriptors for this API key, if not
* provided then permissions of authenticated user are applied.
* @param {string} [expiration] Optional expiration for the API key being generated. If expiration
* is not provided then the API keys do not expire.
*
* @returns {{id: string, name: string, api_key: string, expiration?: number}}
*/
shield.createAPIKey = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/api_key',
},
});
};
}));

View file

@ -0,0 +1,60 @@
/*
* 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 { createAPIKey } from './api_keys';
import { loggingServiceMock } from '../../../../../src/core/server/mocks';
const mockCallAsCurrentUser = jest.fn();
beforeAll(() => jest.resetAllMocks());
describe('createAPIKey()', () => {
it('returns null when security feature is disabled', async () => {
const result = await createAPIKey({
body: {
name: '',
role_descriptors: {},
},
loggers: loggingServiceMock.create(),
callAsCurrentUser: mockCallAsCurrentUser,
isSecurityFeatureDisabled: () => true,
});
expect(result).toBeNull();
expect(mockCallAsCurrentUser).not.toHaveBeenCalled();
});
it('calls callCluster with proper body arguments', async () => {
mockCallAsCurrentUser.mockResolvedValueOnce({
id: '123',
name: 'key-name',
expiration: '1d',
api_key: 'abc123',
});
const result = await createAPIKey({
body: {
name: 'key-name',
role_descriptors: { foo: true },
expiration: '1d',
},
loggers: loggingServiceMock.create(),
callAsCurrentUser: mockCallAsCurrentUser,
isSecurityFeatureDisabled: () => false,
});
expect(result).toEqual({
api_key: 'abc123',
expiration: '1d',
id: '123',
name: 'key-name',
});
expect(mockCallAsCurrentUser).toHaveBeenCalledWith('shield.createAPIKey', {
body: {
name: 'key-name',
role_descriptors: { foo: true },
expiration: '1d',
},
});
});
});

View file

@ -0,0 +1,65 @@
/*
* 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 { LoggerFactory, ScopedClusterClient } from '../../../../../src/core/server';
export interface CreateAPIKeyOptions {
loggers: LoggerFactory;
callAsCurrentUser: ScopedClusterClient['callAsCurrentUser'];
isSecurityFeatureDisabled: () => boolean;
body: {
name: string;
role_descriptors: Record<string, any>;
expiration?: string;
};
}
/**
* The return value when creating an API key in Elasticsearch. The API key returned by this API
* can then be used by sending a request with a Authorization header with a value having the
* prefix ApiKey `{token}` where token is id and api_key joined by a colon `{id}:{api_key}` and
* then encoded to base64.
*/
export interface CreateAPIKeyResult {
/**
* Unique id for this API key
*/
id: string;
/**
* Name for this API key
*/
name: string;
/**
* Optional expiration in milliseconds for this API key
*/
expiration?: number;
/**
* Generated API key
*/
api_key: string;
}
export async function createAPIKey({
body,
loggers,
callAsCurrentUser,
isSecurityFeatureDisabled,
}: CreateAPIKeyOptions): Promise<CreateAPIKeyResult | null> {
const logger = loggers.get('api-keys');
if (isSecurityFeatureDisabled()) {
return null;
}
logger.debug('Trying to create an API key');
// User needs `manage_api_key` privilege to use this API
const key = (await callAsCurrentUser('shield.createAPIKey', { body })) as CreateAPIKeyResult;
logger.debug('API key was created successfully');
return key;
}

View file

@ -5,6 +5,7 @@
*/
jest.mock('./authenticator');
jest.mock('./api_keys', () => ({ createAPIKey: jest.fn() }));
import Boom from 'boom';
import { errors } from 'elasticsearch';
@ -35,6 +36,7 @@ import { getErrorStatusCode } from '../errors';
import { LegacyAPI } from '../plugin';
import { AuthenticationResult } from './authentication_result';
import { setupAuthentication } from '.';
import { CreateAPIKeyResult, CreateAPIKeyOptions } from './api_keys';
function mockXPackFeature({ isEnabled = true }: Partial<{ isEnabled: boolean }> = {}) {
return {
@ -336,4 +338,33 @@ describe('setupAuthentication()', () => {
);
});
});
describe('createAPIKey()', () => {
let createAPIKey: (
request: KibanaRequest,
body: CreateAPIKeyOptions['body']
) => Promise<CreateAPIKeyResult | null>;
beforeEach(async () => {
createAPIKey = (await setupAuthentication(mockSetupAuthenticationParams)).createAPIKey;
});
it('calls createAPIKey with given arguments', async () => {
const { createAPIKey: createAPIKeyMock } = jest.requireMock('./api_keys');
const options = {
name: 'my-key',
role_descriptors: {},
expiration: '1d',
};
createAPIKeyMock.mockResolvedValueOnce({ success: true });
await expect(createAPIKey(httpServerMock.createKibanaRequest(), options)).resolves.toEqual({
success: true,
});
expect(createAPIKeyMock).toHaveBeenCalledWith({
body: options,
loggers: mockSetupAuthenticationParams.loggers,
callAsCurrentUser: mockScopedClusterClient.callAsCurrentUser,
isSecurityFeatureDisabled: expect.any(Function),
});
});
});
});

View file

@ -16,6 +16,7 @@ import { ConfigType } from '../config';
import { getErrorStatusCode, wrapError } from '../errors';
import { Authenticator, ProviderSession } from './authenticator';
import { LegacyAPI } from '../plugin';
import { createAPIKey, CreateAPIKeyOptions } from './api_keys';
export { canRedirectRequest } from './can_redirect_request';
export { Authenticator, ProviderLoginAttempt } from './authenticator';
@ -134,6 +135,13 @@ export async function setupAuthentication({
login: authenticator.login.bind(authenticator),
logout: authenticator.logout.bind(authenticator),
getCurrentUser,
createAPIKey: (request: KibanaRequest, body: CreateAPIKeyOptions['body']) =>
createAPIKey({
body,
loggers,
isSecurityFeatureDisabled,
callAsCurrentUser: clusterClient.asScoped(request).callAsCurrentUser,
}),
isAuthenticated: async (request: KibanaRequest) => {
try {
await getCurrentUser(request);

View file

@ -36,6 +36,7 @@ describe('Security Plugin', () => {
await expect(plugin.setup(mockCoreSetup)).resolves.toMatchInlineSnapshot(`
Object {
"authc": Object {
"createAPIKey": [Function],
"getCurrentUser": [Function],
"isAuthenticated": [Function],
"login": [Function],

View file

@ -18,6 +18,7 @@ import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_i
import { AuthenticatedUser } from '../common/model';
import { Authenticator, setupAuthentication } from './authentication';
import { createConfig$ } from './config';
import { CreateAPIKeyOptions, CreateAPIKeyResult } from './authentication/api_keys';
/**
* Describes a set of APIs that is available in the legacy platform only and required by this plugin
@ -37,6 +38,10 @@ export interface PluginSetupContract {
logout: Authenticator['logout'];
getCurrentUser: (request: KibanaRequest) => Promise<AuthenticatedUser | null>;
isAuthenticated: (request: KibanaRequest) => Promise<boolean>;
createAPIKey: (
request: KibanaRequest,
body: CreateAPIKeyOptions['body']
) => Promise<CreateAPIKeyResult | null>;
};
config: RecursiveReadonly<{