Add invalidateAPIKey support to security plugin (#43707)

* Initial work

* Fix failing jest test

* Use APIKeys class

* Only use id to invalidate

* Log all errors in invalidate function

* Cleanup

* Apply PR feedback
This commit is contained in:
Mike Côté 2019-08-27 10:21:27 -04:00 committed by GitHub
parent 77e95eacdd
commit 0f2324e445
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 323 additions and 86 deletions

View file

@ -516,5 +516,25 @@
fmt: '/_security/api_key',
},
});
/**
* Invalidates an API key in Elasticsearch.
*
* @param {string} [id] An API key id.
* @param {string} [name] An API key name.
* @param {string} [realm_name] The name of an authentication realm.
* @param {string} [username] The username of a user.
*
* NOTE: While all parameters are optional, at least one of them is required.
*
* @returns {{invalidated_api_keys: string[], previously_invalidated_api_keys: string[], error_count: number, error_details?: object[]}}
*/
shield.invalidateAPIKey = ca({
method: 'DELETE',
needBody: true,
url: {
fmt: '/_security/api_key',
},
});
};
}));

View file

@ -4,57 +4,136 @@
* 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';
import { APIKeys } from './api_keys';
import { ClusterClient, ScopedClusterClient } from '../../../../../src/core/server';
import {
httpServerMock,
loggingServiceMock,
elasticsearchServiceMock,
} from '../../../../../src/core/server/mocks';
const mockCallAsCurrentUser = jest.fn();
describe('API Keys', () => {
let apiKeys: APIKeys;
let mockClusterClient: jest.Mocked<PublicMethodsOf<ClusterClient>>;
let mockScopedClusterClient: jest.Mocked<PublicMethodsOf<ScopedClusterClient>>;
const mockIsSecurityFeatureDisabled = 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,
beforeEach(() => {
mockClusterClient = elasticsearchServiceMock.createClusterClient();
mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockClusterClient.asScoped.mockReturnValue((mockScopedClusterClient as unknown) as jest.Mocked<
ScopedClusterClient
>);
mockIsSecurityFeatureDisabled.mockReturnValue(false);
apiKeys = new APIKeys({
clusterClient: mockClusterClient,
logger: loggingServiceMock.create().get('api-keys'),
isSecurityFeatureDisabled: mockIsSecurityFeatureDisabled,
});
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',
describe('create()', () => {
it('returns null when security feature is disabled', async () => {
mockIsSecurityFeatureDisabled.mockReturnValue(true);
const result = await apiKeys.create(httpServerMock.createKibanaRequest(), {
name: '',
role_descriptors: {},
});
expect(result).toBeNull();
expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled();
});
const result = await createAPIKey({
body: {
it('calls callCluster with proper parameters', async () => {
mockIsSecurityFeatureDisabled.mockReturnValue(false);
mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({
id: '123',
name: 'key-name',
expiration: '1d',
api_key: 'abc123',
});
const result = await apiKeys.create(httpServerMock.createKibanaRequest(), {
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 },
});
expect(result).toEqual({
api_key: 'abc123',
expiration: '1d',
},
id: '123',
name: 'key-name',
});
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(
'shield.createAPIKey',
{
body: {
name: 'key-name',
role_descriptors: { foo: true },
expiration: '1d',
},
}
);
});
});
describe('invalidate()', () => {
it('returns null when security feature is disabled', async () => {
mockIsSecurityFeatureDisabled.mockReturnValue(true);
const result = await apiKeys.invalidate(httpServerMock.createKibanaRequest(), {
id: '123',
});
expect(result).toBeNull();
expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled();
});
it('calls callCluster with proper parameters', async () => {
mockIsSecurityFeatureDisabled.mockReturnValue(false);
mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({
invalidated_api_keys: ['api-key-id-1'],
previously_invalidated_api_keys: [],
error_count: 0,
});
const result = await apiKeys.invalidate(httpServerMock.createKibanaRequest(), {
id: '123',
});
expect(result).toEqual({
invalidated_api_keys: ['api-key-id-1'],
previously_invalidated_api_keys: [],
error_count: 0,
});
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(
'shield.invalidateAPIKey',
{
body: {
id: '123',
},
}
);
});
it(`Only passes id as a parameter`, async () => {
mockIsSecurityFeatureDisabled.mockReturnValue(false);
mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({
invalidated_api_keys: ['api-key-id-1'],
previously_invalidated_api_keys: [],
error_count: 0,
});
const result = await apiKeys.invalidate(httpServerMock.createKibanaRequest(), {
id: '123',
name: 'abc',
} as any);
expect(result).toEqual({
invalidated_api_keys: ['api-key-id-1'],
previously_invalidated_api_keys: [],
error_count: 0,
});
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(
'shield.invalidateAPIKey',
{
body: {
id: '123',
},
}
);
});
});
});

View file

@ -4,17 +4,32 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { LoggerFactory, ScopedClusterClient } from '../../../../../src/core/server';
import { ClusterClient, KibanaRequest, Logger } from '../../../../../src/core/server';
export interface CreateAPIKeyOptions {
loggers: LoggerFactory;
callAsCurrentUser: ScopedClusterClient['callAsCurrentUser'];
/**
* Represents the options to create an APIKey class instance that will be
* shared between functions (create, invalidate, etc).
*/
export interface ConstructorOptions {
logger: Logger;
clusterClient: PublicMethodsOf<ClusterClient>;
isSecurityFeatureDisabled: () => boolean;
body: {
name: string;
role_descriptors: Record<string, any>;
expiration?: string;
};
}
/**
* Represents the params for creating an API key
*/
export interface CreateAPIKeyParams {
name: string;
role_descriptors: Record<string, any>;
expiration?: string;
}
/**
* Represents the params for invalidating an API key
*/
export interface InvalidateAPIKeyParams {
id: string;
}
/**
@ -42,24 +57,110 @@ export interface CreateAPIKeyResult {
api_key: string;
}
export async function createAPIKey({
body,
loggers,
callAsCurrentUser,
isSecurityFeatureDisabled,
}: CreateAPIKeyOptions): Promise<CreateAPIKeyResult | null> {
const logger = loggers.get('api-keys');
/**
* The return value when invalidating an API key in Elasticsearch.
*/
export interface InvalidateAPIKeyResult {
/**
* The IDs of the API keys that were invalidated as part of the request.
*/
invalidated_api_keys: string[];
/**
* The IDs of the API keys that were already invalidated.
*/
previously_invalidated_api_keys: string[];
/**
* The number of errors that were encountered when invalidating the API keys.
*/
error_count: number;
/**
* Details about these errors. This field is not present in the response when error_count is 0.
*/
error_details?: Array<{
type: string;
reason: string;
caused_by: {
type: string;
reason: string;
};
}>;
}
if (isSecurityFeatureDisabled()) {
return null;
/**
* Class responsible for managing Elasticsearch API keys.
*/
export class APIKeys {
private readonly logger: Logger;
private readonly clusterClient: PublicMethodsOf<ClusterClient>;
private readonly isSecurityFeatureDisabled: () => boolean;
constructor({ logger, clusterClient, isSecurityFeatureDisabled }: ConstructorOptions) {
this.logger = logger;
this.clusterClient = clusterClient;
this.isSecurityFeatureDisabled = isSecurityFeatureDisabled;
}
logger.debug('Trying to create an API key');
/**
* Tries to create an API key for the current user.
* @param request Request instance.
* @param params The params to create an API key
*/
async create(
request: KibanaRequest,
params: CreateAPIKeyParams
): Promise<CreateAPIKeyResult | null> {
if (this.isSecurityFeatureDisabled()) {
return null;
}
// User needs `manage_api_key` privilege to use this API
const key = (await callAsCurrentUser('shield.createAPIKey', { body })) as CreateAPIKeyResult;
this.logger.debug('Trying to create an API key');
logger.debug('API key was created successfully');
// User needs `manage_api_key` privilege to use this API
let result: CreateAPIKeyResult;
try {
result = (await this.clusterClient
.asScoped(request)
.callAsCurrentUser('shield.createAPIKey', { body: params })) as CreateAPIKeyResult;
this.logger.debug('API key was created successfully');
} catch (e) {
this.logger.error(`Failed to create API key: ${e.message}`);
throw e;
}
return key;
return result;
}
/**
* Tries to invalidate an API key.
* @param request Request instance.
* @param params The params to invalidate an API key.
*/
async invalidate(
request: KibanaRequest,
params: InvalidateAPIKeyParams
): Promise<InvalidateAPIKeyResult | null> {
if (this.isSecurityFeatureDisabled()) {
return null;
}
this.logger.debug('Trying to invalidate an API key');
// User needs `manage_api_key` privilege to use this API
let result: InvalidateAPIKeyResult;
try {
result = (await this.clusterClient
.asScoped(request)
.callAsCurrentUser('shield.invalidateAPIKey', {
body: {
id: params.id,
},
})) as InvalidateAPIKeyResult;
this.logger.debug('API key was invalidated successfully');
} catch (e) {
this.logger.error(`Failed to invalidate API key: ${e.message}`);
throw e;
}
return result;
}
}

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('./api_keys');
jest.mock('./authenticator');
jest.mock('./api_keys', () => ({ createAPIKey: jest.fn() }));
import Boom from 'boom';
import { errors } from 'elasticsearch';
@ -35,7 +35,12 @@ import { ConfigType, createConfig$ } from '../config';
import { LegacyAPI } from '../plugin';
import { AuthenticationResult } from './authentication_result';
import { setupAuthentication } from '.';
import { CreateAPIKeyResult, CreateAPIKeyOptions } from './api_keys';
import {
CreateAPIKeyResult,
CreateAPIKeyParams,
InvalidateAPIKeyResult,
InvalidateAPIKeyParams,
} from './api_keys';
function mockXPackFeature({ isEnabled = true }: Partial<{ isEnabled: boolean }> = {}) {
return {
@ -358,29 +363,49 @@ describe('setupAuthentication()', () => {
describe('createAPIKey()', () => {
let createAPIKey: (
request: KibanaRequest,
body: CreateAPIKeyOptions['body']
params: CreateAPIKeyParams
) => 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 = {
const request = httpServerMock.createKibanaRequest();
const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0];
const params = {
name: 'my-key',
role_descriptors: {},
expiration: '1d',
};
createAPIKeyMock.mockResolvedValueOnce({ success: true });
await expect(createAPIKey(httpServerMock.createKibanaRequest(), options)).resolves.toEqual({
apiKeysInstance.create.mockResolvedValueOnce({ success: true });
await expect(createAPIKey(request, params)).resolves.toEqual({
success: true,
});
expect(createAPIKeyMock).toHaveBeenCalledWith({
body: options,
loggers: mockSetupAuthenticationParams.loggers,
callAsCurrentUser: mockScopedClusterClient.callAsCurrentUser,
isSecurityFeatureDisabled: expect.any(Function),
expect(apiKeysInstance.create).toHaveBeenCalledWith(request, params);
});
});
describe('invalidateAPIKey()', () => {
let invalidateAPIKey: (
request: KibanaRequest,
params: InvalidateAPIKeyParams
) => Promise<InvalidateAPIKeyResult | null>;
beforeEach(async () => {
invalidateAPIKey = (await setupAuthentication(mockSetupAuthenticationParams))
.invalidateAPIKey;
});
it('calls invalidateAPIKey with given arguments', async () => {
const request = httpServerMock.createKibanaRequest();
const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0];
const params = {
id: '123',
};
apiKeysInstance.invalidate.mockResolvedValueOnce({ success: true });
await expect(invalidateAPIKey(request, params)).resolves.toEqual({
success: true,
});
expect(apiKeysInstance.invalidate).toHaveBeenCalledWith(request, params);
});
});
});

View file

@ -14,7 +14,7 @@ import { ConfigType } from '../config';
import { getErrorStatusCode } from '../errors';
import { Authenticator, ProviderSession } from './authenticator';
import { LegacyAPI } from '../plugin';
import { createAPIKey, CreateAPIKeyOptions } from './api_keys';
import { APIKeys, CreateAPIKeyParams, InvalidateAPIKeyParams } from './api_keys';
export { canRedirectRequest } from './can_redirect_request';
export { Authenticator, ProviderLoginAttempt } from './authenticator';
@ -137,17 +137,19 @@ export async function setupAuthentication({
authLogger.debug('Successfully registered core authentication handler.');
const apiKeys = new APIKeys({
clusterClient,
logger: loggers.get('api-key'),
isSecurityFeatureDisabled,
});
return {
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,
}),
createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) =>
apiKeys.create(request, params),
invalidateAPIKey: (request: KibanaRequest, params: InvalidateAPIKeyParams) =>
apiKeys.invalidate(request, params),
isAuthenticated: async (request: KibanaRequest) => {
try {
await getCurrentUser(request);

View file

@ -38,6 +38,7 @@ describe('Security Plugin', () => {
"authc": Object {
"createAPIKey": [Function],
"getCurrentUser": [Function],
"invalidateAPIKey": [Function],
"isAuthenticated": [Function],
"login": [Function],
"logout": [Function],

View file

@ -18,7 +18,12 @@ 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';
import {
CreateAPIKeyParams,
CreateAPIKeyResult,
InvalidateAPIKeyParams,
InvalidateAPIKeyResult,
} from './authentication/api_keys';
/**
* Describes a set of APIs that is available in the legacy platform only and required by this plugin
@ -40,8 +45,12 @@ export interface PluginSetupContract {
isAuthenticated: (request: KibanaRequest) => Promise<boolean>;
createAPIKey: (
request: KibanaRequest,
body: CreateAPIKeyOptions['body']
params: CreateAPIKeyParams
) => Promise<CreateAPIKeyResult | null>;
invalidateAPIKey: (
request: KibanaRequest,
params: InvalidateAPIKeyParams
) => Promise<InvalidateAPIKeyResult | null>;
};
config: RecursiveReadonly<{