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:
parent
77e95eacdd
commit
0f2324e445
|
@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
}));
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -38,6 +38,7 @@ describe('Security Plugin', () => {
|
|||
"authc": Object {
|
||||
"createAPIKey": [Function],
|
||||
"getCurrentUser": [Function],
|
||||
"invalidateAPIKey": [Function],
|
||||
"isAuthenticated": [Function],
|
||||
"login": [Function],
|
||||
"logout": [Function],
|
||||
|
|
|
@ -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<{
|
||||
|
|
Loading…
Reference in a new issue