Skip to content

Commit

Permalink
[Security/APIKey Service] Internal API endpoint do determine if user …
Browse files Browse the repository at this point in the history
…has API keys (#172884)

## Summary

This PR enhances the Security server plugin APIKey service with a new
method to determine if the user has API keys. The service is integrated
into the "No Data Page" server plugin as a new HTTP route. This route is
called when needed to direct users effectively through their getting
started experience.

Pulled from #172225


[Context](#172225 (comment))

> ...we [sh]ould introduce whatever functionality we required into the
security plugin's API Key Service.

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Aleh Zasypkin <[email protected]>
  • Loading branch information
tsullivan and azasypkin authored Dec 21, 2023
1 parent 229e7ef commit b13974c
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 0 deletions.
113 changes: 113 additions & 0 deletions x-pack/plugins/security/server/routes/api_keys/has_active.test.ts
Original file line number Diff line number Diff line change
@@ -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<any, any, any, any>;
let authc: DeeplyMockedKeys<InternalAuthenticationServiceStart>;
let esClientMock: ScopedClusterClientMock;
let mockContext: CustomRequestHandlerMock<unknown>;

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.",
});
});
});
59 changes: 59 additions & 0 deletions x-pack/plugins/security/server/routes/api_keys/has_active.ts
Original file line number Diff line number Diff line change
@@ -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<HasAPIKeysResult>({
body: {
hasApiKeys: apiKeys.length > 0,
},
});
})
);
}
2 changes: 2 additions & 0 deletions x-pack/plugins/security/server/routes/api_keys/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '..';
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
}

0 comments on commit b13974c

Please sign in to comment.