diff --git a/x-pack/plugins/security/server/routes/api_keys/has_active.test.ts b/x-pack/plugins/security/server/routes/api_keys/has_active.test.ts new file mode 100644 index 000000000000..d2c38651f439 --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/has_active.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kibanaResponseFactory } from '@kbn/core/server'; +import type { RequestHandler } from '@kbn/core/server'; +import type { CustomRequestHandlerMock, ScopedClusterClientMock } from '@kbn/core/server/mocks'; +import { coreMock, httpServerMock } from '@kbn/core/server/mocks'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; + +import { defineHasApiKeysRoutes } from './has_active'; +import type { InternalAuthenticationServiceStart } from '../../authentication'; +import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('Has API Keys route', () => { + let routeHandler: RequestHandler; + let authc: DeeplyMockedKeys; + let esClientMock: ScopedClusterClientMock; + let mockContext: CustomRequestHandlerMock; + + beforeEach(async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + authc = authenticationServiceMock.createStart(); + mockRouteDefinitionParams.getAuthenticationService.mockReturnValue(authc); + defineHasApiKeysRoutes(mockRouteDefinitionParams); + [[, routeHandler]] = mockRouteDefinitionParams.router.get.mock.calls; + mockContext = coreMock.createCustomRequestHandlerContext({ + core: coreMock.createRequestHandlerContext(), + licensing: licensingMock.createRequestHandlerContext(), + }); + + esClientMock = (await mockContext.core).elasticsearch.client; + + authc.apiKeys.areAPIKeysEnabled.mockResolvedValue(true); + authc.apiKeys.areCrossClusterAPIKeysEnabled.mockResolvedValue(true); + + esClientMock.asCurrentUser.security.getApiKey.mockResponse({ + api_keys: [ + { id: '123', invalidated: false }, + { id: '456', invalidated: true }, + ], + } as any); + }); + + it('should calculate when user has API keys', async () => { + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.payload).toEqual( + expect.objectContaining({ + hasApiKeys: true, + }) + ); + expect(esClientMock.asCurrentUser.security.getApiKey).toHaveBeenCalledTimes(1); + expect(esClientMock.asCurrentUser.security.getApiKey).toHaveBeenCalledWith({ + owner: true, + active_only: true, + }); + }); + + it('should calculate when user does not have API keys', async () => { + esClientMock.asCurrentUser.security.getApiKey.mockResponse({ + api_keys: [], + }); + + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.payload).toEqual( + expect.objectContaining({ + hasApiKeys: false, + }) + ); + }); + + it('should filter out invalidated API keys', async () => { + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.status).toBe(200); + expect(response.payload?.hasApiKeys).toBe(true); + }); + + it('should return `404` if API keys are disabled', async () => { + authc.apiKeys.areAPIKeysEnabled.mockResolvedValue(false); + + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.status).toBe(404); + expect(response.payload).toEqual({ + message: + "API keys are disabled in Elasticsearch. To use API keys enable 'xpack.security.authc.api_key.enabled' setting.", + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/api_keys/has_active.ts b/x-pack/plugins/security/server/routes/api_keys/has_active.ts new file mode 100644 index 000000000000..eea5ac71e53a --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/has_active.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RouteDefinitionParams } from '..'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +/** + * Response of Kibana Has API keys endpoint. + */ +export interface HasAPIKeysResult { + hasApiKeys: boolean; +} + +export function defineHasApiKeysRoutes({ + router, + getAuthenticationService, +}: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/api_key/_has_active', + validate: false, + options: { + access: 'internal', + }, + }, + createLicensedRouteHandler(async (context, _request, response) => { + const esClient = (await context.core).elasticsearch.client; + const authenticationService = getAuthenticationService(); + + const areApiKeysEnabled = await authenticationService.apiKeys.areAPIKeysEnabled(); + + if (!areApiKeysEnabled) { + return response.notFound({ + body: { + message: + "API keys are disabled in Elasticsearch. To use API keys enable 'xpack.security.authc.api_key.enabled' setting.", + }, + }); + } + + const { api_keys: apiKeys } = await esClient.asCurrentUser.security.getApiKey({ + owner: true, + // @ts-expect-error @elastic/elasticsearch SecurityGetApiKeyRequest.active_only: boolean | undefined + active_only: true, + }); + + // simply return true if the result array is non-empty + return response.ok({ + body: { + hasApiKeys: apiKeys.length > 0, + }, + }); + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/api_keys/index.ts b/x-pack/plugins/security/server/routes/api_keys/index.ts index 9855d94923c3..3b71eb7e1104 100644 --- a/x-pack/plugins/security/server/routes/api_keys/index.ts +++ b/x-pack/plugins/security/server/routes/api_keys/index.ts @@ -8,6 +8,7 @@ import { defineCreateApiKeyRoutes } from './create'; import { defineEnabledApiKeysRoutes } from './enabled'; import { defineGetApiKeysRoutes } from './get'; +import { defineHasApiKeysRoutes } from './has_active'; import { defineInvalidateApiKeysRoutes } from './invalidate'; import { defineUpdateApiKeyRoutes } from './update'; import type { RouteDefinitionParams } from '..'; @@ -24,6 +25,7 @@ export type { GetAPIKeysResult } from './get'; export function defineApiKeysRoutes(params: RouteDefinitionParams) { defineEnabledApiKeysRoutes(params); defineGetApiKeysRoutes(params); + defineHasApiKeysRoutes(params); defineCreateApiKeyRoutes(params); defineUpdateApiKeyRoutes(params); defineInvalidateApiKeysRoutes(params); diff --git a/x-pack/test/security_api_integration/tests/api_keys/has_active_key.ts b/x-pack/test/security_api_integration/tests/api_keys/has_active_key.ts new file mode 100644 index 000000000000..11b118abb1d6 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/api_keys/has_active_key.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import type { ApiKey } from '@kbn/security-plugin/common/model'; +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const createKey = async () => { + const { body: apiKey } = await supertest + .post('/api_keys/_grant') + .set('kbn-xsrf', 'xxx') + .send({ name: 'an-actual-api-key' }) + .expect(200); + expect(apiKey.name).to.eql('an-actual-api-key'); + return apiKey; + }; + + const cleanup = async () => { + // get existing keys which would affect test results + const { body: getResponseBody } = await supertest.get('/internal/security/api_key').expect(200); + const apiKeys: ApiKey[] = getResponseBody.apiKeys; + const existing = apiKeys.map(({ id, name }) => ({ id, name })); + + // invalidate the keys + await supertest + .post(`/internal/security/api_key/invalidate`) + .set('kbn-xsrf', 'xxx') + .send({ apiKeys: existing, isAdmin: false }) + .expect(200, { itemsInvalidated: existing, errors: [] }); + }; + + describe('Has Active API Keys: _has_active', () => { + before(cleanup); + after(cleanup); + + it('detects when user has no API Keys', async () => { + await supertest + .get('/internal/security/api_key/_has_active') + .set('kbn-xsrf', 'xxx') + .expect(200, { hasApiKeys: false }); + }); + + it('detects when user has some API Keys', async () => { + await createKey(); + + await supertest + .get('/internal/security/api_key/_has_active') + .set('kbn-xsrf', 'xxx') + .expect(200, { hasApiKeys: true }); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/api_keys/index.ts b/x-pack/test/security_api_integration/tests/api_keys/index.ts index a20f0a30181f..a36a76c0c956 100644 --- a/x-pack/test/security_api_integration/tests/api_keys/index.ts +++ b/x-pack/test/security_api_integration/tests/api_keys/index.ts @@ -10,5 +10,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('security APIs - Api Keys', function () { loadTestFile(require.resolve('./grant_api_key')); + loadTestFile(require.resolve('./has_active_key')); }); }