diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index 32f341a9c1b7c..2e2aaf688e8b6 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -163,7 +163,7 @@ required by {kib}. If you want to use Third Party initiated SSO , then you must + [source,yaml] -------------------------------------------------------------------------------- -server.xsrf.whitelist: [/api/security/v1/oidc] +server.xsrf.whitelist: [/api/security/oidc/initiate_login] -------------------------------------------------------------------------------- [float] diff --git a/test/common/services/security/user.ts b/test/common/services/security/user.ts index e1c9b3fb998ad..ae02127043234 100644 --- a/test/common/services/security/user.ts +++ b/test/common/services/security/user.ts @@ -38,7 +38,7 @@ export class User { public async create(username: string, user: any) { this.log.debug(`creating user ${username}`); const { data, status, statusText } = await this.axios.post( - `/api/security/v1/users/${username}`, + `/internal/security/users/${username}`, { username, ...user, @@ -55,7 +55,7 @@ export class User { public async delete(username: string) { this.log.debug(`deleting user ${username}`); const { data, status, statusText } = await this.axios.delete( - `/api/security/v1/users/${username}` + `/internal/security/users/${username}` ); if (status !== 204) { throw new Error( diff --git a/x-pack/legacy/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts b/x-pack/legacy/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts index 9591dfdecbfef..66f2a8d1ac79f 100644 --- a/x-pack/legacy/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts +++ b/x-pack/legacy/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts @@ -183,7 +183,7 @@ async function createOrUpdateUser(newUser: User) { async function createUser(newUser: User) { const user = await callKibana({ method: 'POST', - url: `/api/security/v1/users/${newUser.username}`, + url: `/internal/security/users/${newUser.username}`, data: { ...newUser, enabled: true, @@ -209,7 +209,7 @@ async function updateUser(existingUser: User, newUser: User) { // assign role to user await callKibana({ method: 'POST', - url: `/api/security/v1/users/${username}`, + url: `/internal/security/users/${username}`, data: { ...existingUser, roles: allRoles } }); @@ -219,7 +219,7 @@ async function updateUser(existingUser: User, newUser: User) { async function getUser(username: string) { try { return await callKibana({ - url: `/api/security/v1/users/${username}` + url: `/internal/security/users/${username}` }); } catch (e) { const err = e as AxiosError; diff --git a/x-pack/legacy/plugins/security/common/model/index.ts b/x-pack/legacy/plugins/security/common/model.ts similarity index 84% rename from x-pack/legacy/plugins/security/common/model/index.ts rename to x-pack/legacy/plugins/security/common/model.ts index 6c2976815559b..90e6a5403dfe8 100644 --- a/x-pack/legacy/plugins/security/common/model/index.ts +++ b/x-pack/legacy/plugins/security/common/model.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ApiKey } from './api_key'; export { + ApiKey, + ApiKeyToInvalidate, AuthenticatedUser, BuiltinESPrivileges, EditUser, @@ -19,4 +20,4 @@ export { User, canUserChangePassword, getUserDisplayName, -} from '../../../../../plugins/security/common/model'; +} from '../../../../plugins/security/common/model'; diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index 1d798a4a2bc40..be2614f5e4335 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -5,10 +5,6 @@ */ import { resolve } from 'path'; -import { initAuthenticateApi } from './server/routes/api/v1/authenticate'; -import { initUsersApi } from './server/routes/api/v1/users'; -import { initApiKeysApi } from './server/routes/api/v1/api_keys'; -import { initIndicesApi } from './server/routes/api/v1/indices'; import { initOverwrittenSessionView } from './server/routes/views/overwritten_session'; import { initLoginView } from './server/routes/views/login'; import { initLogoutView } from './server/routes/views/logout'; @@ -34,7 +30,7 @@ export const security = (kibana) => new kibana.Plugin({ lifespan: Joi.any().description('This key is handled in the new platform security plugin ONLY'), }).default(), secureCookies: Joi.any().description('This key is handled in the new platform security plugin ONLY'), - loginAssistanceMessage: Joi.string().default(), + loginAssistanceMessage: Joi.any().description('This key is handled in the new platform security plugin ONLY'), authorization: Joi.object({ legacyFallback: Joi.object({ enabled: Joi.boolean().default(true) // deprecated @@ -145,10 +141,6 @@ export const security = (kibana) => new kibana.Plugin({ server.expose({ getUser: request => securityPlugin.authc.getCurrentUser(KibanaRequest.from(request)) }); - initAuthenticateApi(securityPlugin, server); - initUsersApi(securityPlugin, server); - initApiKeysApi(server); - initIndicesApi(server); initLoginView(securityPlugin, server); initLogoutView(server); initLoggedOutView(securityPlugin, server); diff --git a/x-pack/legacy/plugins/security/public/hacks/on_unauthorized_response.js b/x-pack/legacy/plugins/security/public/hacks/on_unauthorized_response.js index 6d03f3da6e2f2..efc227e2c2789 100644 --- a/x-pack/legacy/plugins/security/public/hacks/on_unauthorized_response.js +++ b/x-pack/legacy/plugins/security/public/hacks/on_unauthorized_response.js @@ -11,8 +11,8 @@ import 'plugins/security/services/auto_logout'; function isUnauthorizedResponseAllowed(response) { const API_WHITELIST = [ - '/api/security/v1/login', - '/api/security/v1/users/.*/password' + '/internal/security/login', + '/internal/security/users/.*/password' ]; const url = response.config.url; diff --git a/x-pack/legacy/plugins/security/public/lib/api.ts b/x-pack/legacy/plugins/security/public/lib/api.ts index e6e42ed5bd4da..ffa08ca44f376 100644 --- a/x-pack/legacy/plugins/security/public/lib/api.ts +++ b/x-pack/legacy/plugins/security/public/lib/api.ts @@ -7,12 +7,12 @@ import { kfetch } from 'ui/kfetch'; import { AuthenticatedUser, Role, User, EditUser } from '../../common/model'; -const usersUrl = '/api/security/v1/users'; +const usersUrl = '/internal/security/users'; const rolesUrl = '/api/security/role'; export class UserAPIClient { public async getCurrentUser(): Promise { - return await kfetch({ pathname: `/api/security/v1/me` }); + return await kfetch({ pathname: `/internal/security/me` }); } public async getUsers(): Promise { diff --git a/x-pack/legacy/plugins/security/public/lib/api_keys_api.ts b/x-pack/legacy/plugins/security/public/lib/api_keys_api.ts index c6dcef392af98..fbc0460c5908a 100644 --- a/x-pack/legacy/plugins/security/public/lib/api_keys_api.ts +++ b/x-pack/legacy/plugins/security/public/lib/api_keys_api.ts @@ -5,8 +5,7 @@ */ import { kfetch } from 'ui/kfetch'; -import { ApiKey, ApiKeyToInvalidate } from '../../common/model/api_key'; -import { INTERNAL_API_BASE_PATH } from '../../common/constants'; +import { ApiKey, ApiKeyToInvalidate } from '../../common/model'; interface CheckPrivilegesResponse { areApiKeysEnabled: boolean; @@ -22,7 +21,7 @@ interface GetApiKeysResponse { apiKeys: ApiKey[]; } -const apiKeysUrl = `${INTERNAL_API_BASE_PATH}/api_key`; +const apiKeysUrl = `/internal/security/api_key`; export class ApiKeysApi { public static async checkPrivileges(): Promise { diff --git a/x-pack/legacy/plugins/security/public/objects/lib/get_fields.ts b/x-pack/legacy/plugins/security/public/objects/lib/get_fields.ts index e0998eb8b8f6b..91d98782dab42 100644 --- a/x-pack/legacy/plugins/security/public/objects/lib/get_fields.ts +++ b/x-pack/legacy/plugins/security/public/objects/lib/get_fields.ts @@ -6,7 +6,7 @@ import { IHttpResponse } from 'angular'; import chrome from 'ui/chrome'; -const apiBase = chrome.addBasePath(`/api/security/v1/fields`); +const apiBase = chrome.addBasePath(`/internal/security/fields`); export async function getFields($http: any, query: string): Promise { return await $http diff --git a/x-pack/legacy/plugins/security/public/services/shield_indices.js b/x-pack/legacy/plugins/security/public/services/shield_indices.js index 2e25d73acbcee..973569eb6e9c3 100644 --- a/x-pack/legacy/plugins/security/public/services/shield_indices.js +++ b/x-pack/legacy/plugins/security/public/services/shield_indices.js @@ -10,7 +10,7 @@ const module = uiModules.get('security', []); module.service('shieldIndices', ($http, chrome) => { return { getFields: (query) => { - return $http.get(chrome.addBasePath(`/api/security/v1/fields/${query}`)) + return $http.get(chrome.addBasePath(`/internal/security/fields/${query}`)) .then(response => response.data); } }; diff --git a/x-pack/legacy/plugins/security/public/services/shield_user.js b/x-pack/legacy/plugins/security/public/services/shield_user.js index e77895caaa2ba..53252e851e353 100644 --- a/x-pack/legacy/plugins/security/public/services/shield_user.js +++ b/x-pack/legacy/plugins/security/public/services/shield_user.js @@ -10,7 +10,7 @@ import { uiModules } from 'ui/modules'; const module = uiModules.get('security', ['ngResource']); module.service('ShieldUser', ($resource, chrome) => { - const baseUrl = chrome.addBasePath('/api/security/v1/users/:username'); + const baseUrl = chrome.addBasePath('/internal/security/users/:username'); const ShieldUser = $resource(baseUrl, { username: '@username' }, { @@ -21,7 +21,7 @@ module.service('ShieldUser', ($resource, chrome) => { }, getCurrent: { method: 'GET', - url: chrome.addBasePath('/api/security/v1/me') + url: chrome.addBasePath('/internal/security/me') } }); diff --git a/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.tsx b/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.tsx index acdc29842d4c6..e6d3b5b7536b6 100644 --- a/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.tsx +++ b/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.tsx @@ -190,7 +190,7 @@ class BasicLoginFormUI extends Component { const { username, password } = this.state; - http.post('./api/security/v1/login', { username, password }).then( + http.post('./internal/security/login', { username, password }).then( () => (window.location.href = next), (error: any) => { const { statusCode = 500 } = error.data || {}; diff --git a/x-pack/legacy/plugins/security/public/views/logout/logout.js b/x-pack/legacy/plugins/security/public/views/logout/logout.js index 4411ecdade8e7..5d76dfc2908c8 100644 --- a/x-pack/legacy/plugins/security/public/views/logout/logout.js +++ b/x-pack/legacy/plugins/security/public/views/logout/logout.js @@ -12,5 +12,5 @@ chrome $window.sessionStorage.clear(); // Redirect user to the server logout endpoint to complete logout. - $window.location.href = chrome.addBasePath(`/api/security/v1/logout${$window.location.search}`); + $window.location.href = chrome.addBasePath(`/api/security/logout${$window.location.search}`); }); diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx index 37838cfdb950d..1613e3804c31d 100644 --- a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx @@ -29,7 +29,7 @@ import _ from 'lodash'; import { toastNotifications } from 'ui/notify'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { SectionLoading } from '../../../../../../../../../src/plugins/es_ui_shared/public/components/section_loading'; -import { ApiKey, ApiKeyToInvalidate } from '../../../../../common/model/api_key'; +import { ApiKey, ApiKeyToInvalidate } from '../../../../../common/model'; import { ApiKeysApi } from '../../../../lib/api_keys_api'; import { PermissionDenied } from './permission_denied'; import { EmptyPrompt } from './empty_prompt'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/invalidate_provider.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/invalidate_provider.tsx index fe9ffc651db29..a1627442b89b8 100644 --- a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/invalidate_provider.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/invalidate_provider.tsx @@ -8,7 +8,7 @@ import React, { Fragment, useRef, useState } from 'react'; import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import { toastNotifications } from 'ui/notify'; import { i18n } from '@kbn/i18n'; -import { ApiKeyToInvalidate } from '../../../../../../common/model/api_key'; +import { ApiKeyToInvalidate } from '../../../../../../common/model'; import { ApiKeysApi } from '../../../../../lib/api_keys_api'; interface Props { diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts deleted file mode 100644 index c928a38d88ef3..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 { Request } from 'hapi'; -import url from 'url'; - -interface RequestFixtureOptions { - headers?: Record; - auth?: string; - params?: Record; - path?: string; - basePath?: string; - search?: string; - payload?: unknown; -} - -export function requestFixture({ - headers = { accept: 'something/html' }, - auth, - params, - path = '/wat', - search = '', - payload, -}: RequestFixtureOptions = {}) { - return ({ - raw: { req: { headers } }, - auth, - headers, - params, - url: { path, search }, - query: search ? url.parse(search, true /* parseQueryString */).query : {}, - payload, - state: { user: 'these are the contents of the user client cookie' }, - route: { settings: {} }, - } as any) as Request; -} diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts deleted file mode 100644 index 55b6f735cfced..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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 { stub } from 'sinon'; - -export function serverFixture() { - return { - config: stub(), - register: stub(), - expose: stub(), - log: stub(), - route: stub(), - decorate: stub(), - - info: { - protocol: 'protocol', - }, - - auth: { - strategy: stub(), - test: stub(), - }, - - plugins: { - elasticsearch: { - createCluster: stub(), - }, - - kibana: { - systemApi: { isSystemApiRequest: stub() }, - }, - - security: { - getUser: stub(), - authenticate: stub(), - deauthenticate: stub(), - authorization: { - application: stub(), - }, - }, - - xpack_main: { - info: { - isAvailable: stub(), - feature: stub(), - license: { - isOneOf: stub(), - }, - }, - }, - }, - }; -} diff --git a/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js b/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js deleted file mode 100644 index 64816bf4d23d7..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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. - */ - -const Boom = require('boom'); - -export function routePreCheckLicense(server) { - return function forbidApiAccess() { - const licenseCheckResults = server.newPlatform.setup.plugins.security.__legacyCompat.license.getFeatures(); - if (!licenseCheckResults.showLinks) { - throw Boom.forbidden(licenseCheckResults.linksMessage); - } else { - return null; - } - }; -} diff --git a/x-pack/legacy/plugins/security/server/lib/user_schema.js b/x-pack/legacy/plugins/security/server/lib/user_schema.js deleted file mode 100644 index 57c66b2712025..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/user_schema.js +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 Joi from 'joi'; - -export const userSchema = Joi.object({ - username: Joi.string().required(), - password: Joi.string(), - roles: Joi.array().items(Joi.string()), - full_name: Joi.string().allow(null, ''), - email: Joi.string().allow(null, ''), - metadata: Joi.object(), - enabled: Joi.boolean().default(true) -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js deleted file mode 100644 index 5cea7c70b7781..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js +++ /dev/null @@ -1,260 +0,0 @@ -/* - * 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 expect from '@kbn/expect'; -import Boom from 'boom'; -import Joi from 'joi'; -import sinon from 'sinon'; - -import { serverFixture } from '../../../../lib/__tests__/__fixtures__/server'; -import { requestFixture } from '../../../../lib/__tests__/__fixtures__/request'; -import { AuthenticationResult, DeauthenticationResult } from '../../../../../../../../plugins/security/server'; -import { initAuthenticateApi } from '../authenticate'; -import { KibanaRequest } from '../../../../../../../../../src/core/server'; - -describe('Authentication routes', () => { - let serverStub; - let hStub; - let loginStub; - let logoutStub; - - beforeEach(() => { - serverStub = serverFixture(); - hStub = { - authenticated: sinon.stub(), - continue: 'blah', - redirect: sinon.stub(), - response: sinon.stub() - }; - loginStub = sinon.stub(); - logoutStub = sinon.stub(); - - initAuthenticateApi({ - authc: { login: loginStub, logout: logoutStub }, - __legacyCompat: { config: { authc: { providers: ['basic'] } } }, - }, serverStub); - }); - - describe('login', () => { - let loginRoute; - let request; - - beforeEach(() => { - loginRoute = serverStub.route - .withArgs(sinon.match({ path: '/api/security/v1/login' })) - .firstCall - .args[0]; - - request = requestFixture({ - headers: {}, - payload: { username: 'user', password: 'password' } - }); - }); - - it('correctly defines route.', async () => { - expect(loginRoute.method).to.be('POST'); - expect(loginRoute.path).to.be('/api/security/v1/login'); - expect(loginRoute.handler).to.be.a(Function); - expect(loginRoute.config).to.eql({ - auth: false, - validate: { - payload: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required() - }) - }, - response: { - emptyStatusCode: 204, - } - }); - }); - - it('returns 500 if authentication throws unhandled exception.', async () => { - const unhandledException = new Error('Something went wrong.'); - loginStub.throws(unhandledException); - - return loginRoute - .handler(request, hStub) - .catch((response) => { - expect(response.isBoom).to.be(true); - expect(response.output.payload).to.eql({ - statusCode: 500, - error: 'Internal Server Error', - message: 'An internal server error occurred' - }); - }); - }); - - it('returns 401 if authentication fails.', async () => { - const failureReason = new Error('Something went wrong.'); - loginStub.resolves(AuthenticationResult.failed(failureReason)); - - return loginRoute - .handler(request, hStub) - .catch((response) => { - expect(response.isBoom).to.be(true); - expect(response.message).to.be(failureReason.message); - expect(response.output.statusCode).to.be(401); - }); - }); - - it('returns 401 if authentication is not handled.', async () => { - loginStub.resolves(AuthenticationResult.notHandled()); - - return loginRoute - .handler(request, hStub) - .catch((response) => { - expect(response.isBoom).to.be(true); - expect(response.message).to.be('Unauthorized'); - expect(response.output.statusCode).to.be(401); - }); - }); - - describe('authentication succeeds', () => { - - it(`returns user data`, async () => { - loginStub.resolves(AuthenticationResult.succeeded({ username: 'user' })); - - await loginRoute.handler(request, hStub); - - sinon.assert.calledOnce(hStub.response); - sinon.assert.calledOnce(loginStub); - sinon.assert.calledWithExactly( - loginStub, - sinon.match.instanceOf(KibanaRequest), - { provider: 'basic', value: { username: 'user', password: 'password' } } - ); - }); - }); - - }); - - describe('logout', () => { - let logoutRoute; - - beforeEach(() => { - serverStub.config.returns({ - get: sinon.stub().withArgs('server.basePath').returns('/test-base-path') - }); - - logoutRoute = serverStub.route - .withArgs(sinon.match({ path: '/api/security/v1/logout' })) - .firstCall - .args[0]; - }); - - it('correctly defines route.', async () => { - expect(logoutRoute.method).to.be('GET'); - expect(logoutRoute.path).to.be('/api/security/v1/logout'); - expect(logoutRoute.handler).to.be.a(Function); - expect(logoutRoute.config).to.eql({ auth: false }); - }); - - it('returns 500 if deauthentication throws unhandled exception.', async () => { - const request = requestFixture(); - - const unhandledException = new Error('Something went wrong.'); - logoutStub.rejects(unhandledException); - - return logoutRoute - .handler(request, hStub) - .catch((response) => { - expect(response).to.be(Boom.boomify(unhandledException)); - sinon.assert.notCalled(hStub.redirect); - }); - }); - - it('returns 500 if authenticator fails to logout.', async () => { - const request = requestFixture(); - - const failureReason = Boom.forbidden(); - logoutStub.resolves(DeauthenticationResult.failed(failureReason)); - - return logoutRoute - .handler(request, hStub) - .catch((response) => { - expect(response).to.be(Boom.boomify(failureReason)); - sinon.assert.notCalled(hStub.redirect); - sinon.assert.calledOnce(logoutStub); - sinon.assert.calledWithExactly( - logoutStub, - sinon.match.instanceOf(KibanaRequest) - ); - }); - }); - - it('returns 400 for AJAX requests that can not handle redirect.', async () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); - - return logoutRoute - .handler(request, hStub) - .catch((response) => { - expect(response.isBoom).to.be(true); - expect(response.message).to.be('Client should be able to process redirect response.'); - expect(response.output.statusCode).to.be(400); - sinon.assert.notCalled(hStub.redirect); - }); - }); - - it('redirects user to the URL returned by authenticator.', async () => { - const request = requestFixture(); - - logoutStub.resolves(DeauthenticationResult.redirectTo('https://custom.logout')); - - await logoutRoute.handler(request, hStub); - - sinon.assert.calledOnce(hStub.redirect); - sinon.assert.calledWithExactly(hStub.redirect, 'https://custom.logout'); - }); - - it('redirects user to the base path if deauthentication succeeds.', async () => { - const request = requestFixture(); - - logoutStub.resolves(DeauthenticationResult.succeeded()); - - await logoutRoute.handler(request, hStub); - - sinon.assert.calledOnce(hStub.redirect); - sinon.assert.calledWithExactly(hStub.redirect, '/test-base-path/'); - }); - - it('redirects user to the base path if deauthentication is not handled.', async () => { - const request = requestFixture(); - - logoutStub.resolves(DeauthenticationResult.notHandled()); - - await logoutRoute.handler(request, hStub); - - sinon.assert.calledOnce(hStub.redirect); - sinon.assert.calledWithExactly(hStub.redirect, '/test-base-path/'); - }); - }); - - describe('me', () => { - let meRoute; - - beforeEach(() => { - meRoute = serverStub.route - .withArgs(sinon.match({ path: '/api/security/v1/me' })) - .firstCall - .args[0]; - }); - - it('correctly defines route.', async () => { - expect(meRoute.method).to.be('GET'); - expect(meRoute.path).to.be('/api/security/v1/me'); - expect(meRoute.handler).to.be.a(Function); - expect(meRoute.config).to.be(undefined); - }); - - it('returns user from the authenticated request property.', async () => { - const request = { auth: { credentials: { username: 'user' } } }; - const response = await meRoute.handler(request, hStub); - - expect(response).to.eql({ username: 'user' }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js deleted file mode 100644 index 4077ab52e86de..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js +++ /dev/null @@ -1,214 +0,0 @@ -/* - * 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 expect from '@kbn/expect'; -import Joi from 'joi'; -import sinon from 'sinon'; - -import { serverFixture } from '../../../../lib/__tests__/__fixtures__/server'; -import { requestFixture } from '../../../../lib/__tests__/__fixtures__/request'; -import { AuthenticationResult } from '../../../../../../../../plugins/security/server'; -import { initUsersApi } from '../users'; -import * as ClientShield from '../../../../../../../server/lib/get_client_shield'; -import { KibanaRequest } from '../../../../../../../../../src/core/server'; - -describe('User routes', () => { - const sandbox = sinon.createSandbox(); - - let clusterStub; - let serverStub; - let loginStub; - - beforeEach(() => { - serverStub = serverFixture(); - loginStub = sinon.stub(); - - // Cluster is returned by `getClient` function that is wrapped into `once` making cluster - // a static singleton, so we should use sandbox to set/reset its behavior between tests. - clusterStub = sinon.stub({ callWithRequest() {} }); - sandbox.stub(ClientShield, 'getClient').returns(clusterStub); - - initUsersApi({ authc: { login: loginStub }, __legacyCompat: { config: { authc: { providers: ['basic'] } } } }, serverStub); - }); - - afterEach(() => sandbox.restore()); - - describe('change password', () => { - let changePasswordRoute; - let request; - - beforeEach(() => { - changePasswordRoute = serverStub.route - .withArgs(sinon.match({ path: '/api/security/v1/users/{username}/password' })) - .firstCall - .args[0]; - - request = requestFixture({ - headers: {}, - auth: { credentials: { username: 'user' } }, - params: { username: 'target-user' }, - payload: { password: 'old-password', newPassword: 'new-password' } - }); - }); - - it('correctly defines route.', async () => { - expect(changePasswordRoute.method).to.be('POST'); - expect(changePasswordRoute.path).to.be('/api/security/v1/users/{username}/password'); - expect(changePasswordRoute.handler).to.be.a(Function); - - expect(changePasswordRoute.config).to.not.have.property('auth'); - expect(changePasswordRoute.config).to.have.property('pre'); - expect(changePasswordRoute.config.pre).to.have.length(1); - expect(changePasswordRoute.config.validate).to.eql({ - payload: Joi.object({ - password: Joi.string(), - newPassword: Joi.string().required() - }) - }); - }); - - describe('own password', () => { - beforeEach(() => { - request.params.username = request.auth.credentials.username; - loginStub = loginStub - .withArgs( - sinon.match.instanceOf(KibanaRequest), - { provider: 'basic', value: { username: 'user', password: 'old-password' }, stateless: true } - ) - .resolves(AuthenticationResult.succeeded({})); - }); - - it('returns 403 if old password is wrong.', async () => { - loginStub.resolves(AuthenticationResult.failed(new Error('Something went wrong.'))); - - const response = await changePasswordRoute.handler(request); - - sinon.assert.notCalled(clusterStub.callWithRequest); - expect(response.isBoom).to.be(true); - expect(response.output.payload).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: 'Something went wrong.' - }); - }); - - it(`returns 401 if user can't authenticate with new password.`, async () => { - loginStub - .withArgs( - sinon.match.instanceOf(KibanaRequest), - { provider: 'basic', value: { username: 'user', password: 'new-password' } } - ) - .resolves(AuthenticationResult.failed(new Error('Something went wrong.'))); - - const response = await changePasswordRoute.handler(request); - - sinon.assert.calledOnce(clusterStub.callWithRequest); - sinon.assert.calledWithExactly( - clusterStub.callWithRequest, - sinon.match.same(request), - 'shield.changePassword', - { username: 'user', body: { password: 'new-password' } } - ); - - expect(response.isBoom).to.be(true); - expect(response.output.payload).to.eql({ - statusCode: 401, - error: 'Unauthorized', - message: 'Something went wrong.' - }); - }); - - it('returns 500 if password update request fails.', async () => { - clusterStub.callWithRequest - .withArgs( - sinon.match.same(request), - 'shield.changePassword', - { username: 'user', body: { password: 'new-password' } } - ) - .rejects(new Error('Request failed.')); - - const response = await changePasswordRoute.handler(request); - - expect(response.isBoom).to.be(true); - expect(response.output.payload).to.eql({ - statusCode: 500, - error: 'Internal Server Error', - message: 'An internal server error occurred' - }); - }); - - it('successfully changes own password if provided old password is correct.', async () => { - loginStub - .withArgs( - sinon.match.instanceOf(KibanaRequest), - { provider: 'basic', value: { username: 'user', password: 'new-password' } } - ) - .resolves(AuthenticationResult.succeeded({})); - - const hResponseStub = { code: sinon.stub() }; - const hStub = { response: sinon.stub().returns(hResponseStub) }; - - await changePasswordRoute.handler(request, hStub); - - sinon.assert.calledOnce(clusterStub.callWithRequest); - sinon.assert.calledWithExactly( - clusterStub.callWithRequest, - sinon.match.same(request), - 'shield.changePassword', - { username: 'user', body: { password: 'new-password' } } - ); - - sinon.assert.calledWithExactly(hStub.response); - sinon.assert.calledWithExactly(hResponseStub.code, 204); - }); - }); - - describe('other user password', () => { - it('returns 500 if password update request fails.', async () => { - clusterStub.callWithRequest - .withArgs( - sinon.match.same(request), - 'shield.changePassword', - { username: 'target-user', body: { password: 'new-password' } } - ) - .returns(Promise.reject(new Error('Request failed.'))); - - const response = await changePasswordRoute.handler(request); - - sinon.assert.notCalled(serverStub.plugins.security.getUser); - sinon.assert.notCalled(loginStub); - - expect(response.isBoom).to.be(true); - expect(response.output.payload).to.eql({ - statusCode: 500, - error: 'Internal Server Error', - message: 'An internal server error occurred' - }); - }); - - it('successfully changes user password.', async () => { - const hResponseStub = { code: sinon.stub() }; - const hStub = { response: sinon.stub().returns(hResponseStub) }; - - await changePasswordRoute.handler(request, hStub); - - sinon.assert.notCalled(serverStub.plugins.security.getUser); - sinon.assert.notCalled(loginStub); - - sinon.assert.calledOnce(clusterStub.callWithRequest); - sinon.assert.calledWithExactly( - clusterStub.callWithRequest, - sinon.match.same(request), - 'shield.changePassword', - { username: 'target-user', body: { password: 'new-password' } } - ); - - sinon.assert.calledWithExactly(hStub.response); - sinon.assert.calledWithExactly(hResponseStub.code, 204); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.js deleted file mode 100644 index a236badcd0d6b..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 Joi from 'joi'; -import { wrapError } from '../../../../../../../../plugins/security/server'; -import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; - -export function initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn) { - server.route({ - method: 'GET', - path: `${INTERNAL_API_BASE_PATH}/api_key`, - async handler(request) { - try { - const { isAdmin } = request.query; - - const result = await callWithRequest( - request, - 'shield.getAPIKeys', - { - owner: !isAdmin - } - ); - - const validKeys = result.api_keys.filter(({ invalidated }) => !invalidated); - - return { - apiKeys: validKeys, - }; - } catch (error) { - return wrapError(error); - } - }, - config: { - pre: [routePreCheckLicenseFn], - validate: { - query: Joi.object().keys({ - isAdmin: Joi.bool().required(), - }).required(), - }, - } - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.test.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.test.js deleted file mode 100644 index 400e5b705aeb2..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.test.js +++ /dev/null @@ -1,166 +0,0 @@ -/* - * 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 Hapi from 'hapi'; -import Boom from 'boom'; - -import { initGetApiKeysApi } from './get'; -import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; - -const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 }); - -describe('GET API keys', () => { - const getApiKeysTest = ( - description, - { - preCheckLicenseImpl = () => null, - callWithRequestImpl, - asserts, - isAdmin = true, - } - ) => { - test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - - if (callWithRequestImpl) { - mockCallWithRequest.mockImplementation(callWithRequestImpl); - } - - initGetApiKeysApi(mockServer, mockCallWithRequest, pre); - - const headers = { - authorization: 'foo', - }; - - const request = { - method: 'GET', - url: `${INTERNAL_API_BASE_PATH}/api_key?isAdmin=${isAdmin}`, - headers, - }; - - const { result, statusCode } = await mockServer.inject(request); - - expect(pre).toHaveBeenCalled(); - - if (callWithRequestImpl) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), - 'shield.getAPIKeys', - { - owner: !isAdmin, - }, - ); - } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); - } - - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); - }); - }; - - describe('failure', () => { - getApiKeysTest('returns result of routePreCheckLicense', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, - }); - - getApiKeysTest('returns error from callWithRequest', { - callWithRequestImpl: async () => { - throw Boom.notAcceptable('test not acceptable message'); - }, - asserts: { - statusCode: 406, - result: { - error: 'Not Acceptable', - statusCode: 406, - message: 'test not acceptable message', - }, - }, - }); - }); - - describe('success', () => { - getApiKeysTest('returns API keys', { - callWithRequestImpl: async () => ({ - api_keys: - [{ - id: 'YCLV7m0BJ3xI4hhWB648', - name: 'test-api-key', - creation: 1571670001452, - expiration: 1571756401452, - invalidated: false, - username: 'elastic', - realm: 'reserved' - }] - }), - asserts: { - statusCode: 200, - result: { - apiKeys: - [{ - id: 'YCLV7m0BJ3xI4hhWB648', - name: 'test-api-key', - creation: 1571670001452, - expiration: 1571756401452, - invalidated: false, - username: 'elastic', - realm: 'reserved' - }] - }, - }, - }); - getApiKeysTest('returns only valid API keys', { - callWithRequestImpl: async () => ({ - api_keys: - [{ - id: 'YCLV7m0BJ3xI4hhWB648', - name: 'test-api-key1', - creation: 1571670001452, - expiration: 1571756401452, - invalidated: true, - username: 'elastic', - realm: 'reserved' - }, { - id: 'YCLV7m0BJ3xI4hhWB648', - name: 'test-api-key2', - creation: 1571670001452, - expiration: 1571756401452, - invalidated: false, - username: 'elastic', - realm: 'reserved' - }], - }), - asserts: { - statusCode: 200, - result: { - apiKeys: - [{ - id: 'YCLV7m0BJ3xI4hhWB648', - name: 'test-api-key2', - creation: 1571670001452, - expiration: 1571756401452, - invalidated: false, - username: 'elastic', - realm: 'reserved' - }] - }, - }, - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js deleted file mode 100644 index fc55bdcc38661..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 { getClient } from '../../../../../../../server/lib/get_client_shield'; -import { routePreCheckLicense } from '../../../../lib/route_pre_check_license'; -import { initCheckPrivilegesApi } from './privileges'; -import { initGetApiKeysApi } from './get'; -import { initInvalidateApiKeysApi } from './invalidate'; - -export function initApiKeysApi(server) { - const callWithRequest = getClient(server).callWithRequest; - const routePreCheckLicenseFn = routePreCheckLicense(server); - - initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn); - initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn); - initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.js deleted file mode 100644 index 293142c60be67..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.js +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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 Joi from 'joi'; -import { wrapError } from '../../../../../../../../plugins/security/server'; -import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; - -export function initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn) { - server.route({ - method: 'POST', - path: `${INTERNAL_API_BASE_PATH}/api_key/invalidate`, - async handler(request) { - try { - const { apiKeys, isAdmin } = request.payload; - const itemsInvalidated = []; - const errors = []; - - // Send the request to invalidate the API key and return an error if it could not be deleted. - const sendRequestToInvalidateApiKey = async (id) => { - try { - const body = { id }; - - if (!isAdmin) { - body.owner = true; - } - - await callWithRequest(request, 'shield.invalidateAPIKey', { body }); - return null; - } catch (error) { - return wrapError(error); - } - }; - - const invalidateApiKey = async ({ id, name }) => { - const error = await sendRequestToInvalidateApiKey(id); - if (error) { - errors.push({ id, name, error }); - } else { - itemsInvalidated.push({ id, name }); - } - }; - - // Invalidate all API keys in parallel. - await Promise.all(apiKeys.map((key) => invalidateApiKey(key))); - - return { - itemsInvalidated, - errors, - }; - } catch (error) { - return wrapError(error); - } - }, - config: { - pre: [routePreCheckLicenseFn], - validate: { - payload: Joi.object({ - apiKeys: Joi.array().items(Joi.object({ - id: Joi.string().required(), - name: Joi.string().required(), - })).required(), - isAdmin: Joi.bool().required(), - }) - }, - } - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.test.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.test.js deleted file mode 100644 index 3ed7ca94eb782..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.test.js +++ /dev/null @@ -1,200 +0,0 @@ -/* - * 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 Hapi from 'hapi'; -import Boom from 'boom'; - -import { initInvalidateApiKeysApi } from './invalidate'; -import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; - -const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 }); - -describe('POST invalidate', () => { - const postInvalidateTest = ( - description, - { - preCheckLicenseImpl = () => null, - callWithRequestImpls = [], - asserts, - payload, - } - ) => { - test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - - for (const impl of callWithRequestImpls) { - mockCallWithRequest.mockImplementationOnce(impl); - } - - initInvalidateApiKeysApi(mockServer, mockCallWithRequest, pre); - - const headers = { - authorization: 'foo', - }; - - const request = { - method: 'POST', - url: `${INTERNAL_API_BASE_PATH}/api_key/invalidate`, - headers, - payload, - }; - - const { result, statusCode } = await mockServer.inject(request); - - expect(pre).toHaveBeenCalled(); - - if (asserts.callWithRequests) { - for (const args of asserts.callWithRequests) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), - ...args - ); - } - } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); - } - - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); - }); - }; - - describe('failure', () => { - postInvalidateTest('returns result of routePreCheckLicense', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - payload: { - apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], - isAdmin: true - }, - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, - }); - - postInvalidateTest('returns errors array from callWithRequest', { - callWithRequestImpls: [async () => { - throw Boom.notAcceptable('test not acceptable message'); - }], - payload: { - apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], - isAdmin: true - }, - asserts: { - callWithRequests: [ - ['shield.invalidateAPIKey', { - body: { - id: 'si8If24B1bKsmSLTAhJV', - }, - }], - ], - statusCode: 200, - result: { - itemsInvalidated: [], - errors: [{ - id: 'si8If24B1bKsmSLTAhJV', - name: 'my-api-key', - error: Boom.notAcceptable('test not acceptable message'), - }] - }, - }, - }); - }); - - describe('success', () => { - postInvalidateTest('invalidates API keys', { - callWithRequestImpls: [async () => null], - payload: { - apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], - isAdmin: true - }, - asserts: { - callWithRequests: [ - ['shield.invalidateAPIKey', { - body: { - id: 'si8If24B1bKsmSLTAhJV', - }, - }], - ], - statusCode: 200, - result: { - itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], - errors: [], - }, - }, - }); - - postInvalidateTest('adds "owner" to body if isAdmin=false', { - callWithRequestImpls: [async () => null], - payload: { - apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], - isAdmin: false - }, - asserts: { - callWithRequests: [ - ['shield.invalidateAPIKey', { - body: { - id: 'si8If24B1bKsmSLTAhJV', - owner: true, - }, - }], - ], - statusCode: 200, - result: { - itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], - errors: [], - }, - }, - }); - - postInvalidateTest('returns only successful invalidation requests', { - callWithRequestImpls: [ - async () => null, - async () => { - throw Boom.notAcceptable('test not acceptable message'); - }], - payload: { - apiKeys: [ - { id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' }, - { id: 'ab8If24B1bKsmSLTAhNC', name: 'my-api-key2' } - ], - isAdmin: true - }, - asserts: { - callWithRequests: [ - ['shield.invalidateAPIKey', { - body: { - id: 'si8If24B1bKsmSLTAhJV', - }, - }], - ['shield.invalidateAPIKey', { - body: { - id: 'ab8If24B1bKsmSLTAhNC', - }, - }], - ], - statusCode: 200, - result: { - itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' }], - errors: [{ - id: 'ab8If24B1bKsmSLTAhNC', - name: 'my-api-key2', - error: Boom.notAcceptable('test not acceptable message'), - }] - }, - }, - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.js deleted file mode 100644 index 3aa30c9a3b9bb..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.js +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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 { wrapError } from '../../../../../../../../plugins/security/server'; -import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; - -export function initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn) { - server.route({ - method: 'GET', - path: `${INTERNAL_API_BASE_PATH}/api_key/privileges`, - async handler(request) { - try { - const result = await Promise.all([ - callWithRequest( - request, - 'shield.hasPrivileges', - { - body: { - cluster: [ - 'manage_security', - 'manage_api_key', - ], - }, - } - ), - new Promise(async (resolve, reject) => { - try { - const result = await callWithRequest( - request, - 'shield.getAPIKeys', - { - owner: true - } - ); - // If the API returns a truthy result that means it's enabled. - resolve({ areApiKeysEnabled: !!result }); - } catch (e) { - // This is a brittle dependency upon message. Tracked by https://github.com/elastic/elasticsearch/issues/47759. - if (e.message.includes('api keys are not enabled')) { - return resolve({ areApiKeysEnabled: false }); - } - - // It's a real error, so rethrow it. - reject(e); - } - }), - ]); - - const [{ - cluster: { - manage_security: manageSecurity, - manage_api_key: manageApiKey, - } - }, { - areApiKeysEnabled, - }] = result; - - const isAdmin = manageSecurity || manageApiKey; - - return { - areApiKeysEnabled, - isAdmin, - }; - } catch (error) { - return wrapError(error); - } - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.test.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.test.js deleted file mode 100644 index 2a6f935e00595..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.test.js +++ /dev/null @@ -1,254 +0,0 @@ -/* - * 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 Hapi from 'hapi'; -import Boom from 'boom'; - -import { initCheckPrivilegesApi } from './privileges'; -import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; - -const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 }); - -describe('GET privileges', () => { - const getPrivilegesTest = ( - description, - { - preCheckLicenseImpl = () => null, - callWithRequestImpls = [], - asserts, - } - ) => { - test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - - for (const impl of callWithRequestImpls) { - mockCallWithRequest.mockImplementationOnce(impl); - } - - initCheckPrivilegesApi(mockServer, mockCallWithRequest, pre); - - const headers = { - authorization: 'foo', - }; - - const request = { - method: 'GET', - url: `${INTERNAL_API_BASE_PATH}/api_key/privileges`, - headers, - }; - - const { result, statusCode } = await mockServer.inject(request); - - expect(pre).toHaveBeenCalled(); - - if (asserts.callWithRequests) { - for (const args of asserts.callWithRequests) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), - ...args - ); - } - } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); - } - - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); - }); - }; - - describe('failure', () => { - getPrivilegesTest('returns result of routePreCheckLicense', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, - }); - - getPrivilegesTest('returns error from first callWithRequest', { - callWithRequestImpls: [async () => { - throw Boom.notAcceptable('test not acceptable message'); - }, async () => { }], - asserts: { - callWithRequests: [ - ['shield.hasPrivileges', { - body: { - cluster: [ - 'manage_security', - 'manage_api_key', - ], - }, - }], - ['shield.getAPIKeys', { owner: true }], - ], - statusCode: 406, - result: { - error: 'Not Acceptable', - statusCode: 406, - message: 'test not acceptable message', - }, - }, - }); - - getPrivilegesTest('returns error from second callWithRequest', { - callWithRequestImpls: [async () => { }, async () => { - throw Boom.notAcceptable('test not acceptable message'); - }], - asserts: { - callWithRequests: [ - ['shield.hasPrivileges', { - body: { - cluster: [ - 'manage_security', - 'manage_api_key', - ], - }, - }], - ['shield.getAPIKeys', { owner: true }], - ], - statusCode: 406, - result: { - error: 'Not Acceptable', - statusCode: 406, - message: 'test not acceptable message', - }, - }, - }); - }); - - describe('success', () => { - getPrivilegesTest('returns areApiKeysEnabled and isAdmin', { - callWithRequestImpls: [ - async () => ({ - username: 'elastic', - has_all_requested: true, - cluster: { manage_api_key: true, manage_security: true }, - index: {}, - application: {} - }), - async () => ( - { - api_keys: - [{ - id: 'si8If24B1bKsmSLTAhJV', - name: 'my-api-key', - creation: 1574089261632, - expiration: 1574175661632, - invalidated: false, - username: 'elastic', - realm: 'reserved' - }] - } - ), - ], - asserts: { - callWithRequests: [ - ['shield.getAPIKeys', { owner: true }], - ['shield.hasPrivileges', { - body: { - cluster: [ - 'manage_security', - 'manage_api_key', - ], - }, - }], - ], - statusCode: 200, - result: { - areApiKeysEnabled: true, - isAdmin: true, - }, - }, - }); - - getPrivilegesTest('returns areApiKeysEnabled=false when getAPIKeys error message includes "api keys are not enabled"', { - callWithRequestImpls: [ - async () => ({ - username: 'elastic', - has_all_requested: true, - cluster: { manage_api_key: true, manage_security: true }, - index: {}, - application: {} - }), - async () => { - throw Boom.unauthorized('api keys are not enabled'); - }, - ], - asserts: { - callWithRequests: [ - ['shield.getAPIKeys', { owner: true }], - ['shield.hasPrivileges', { - body: { - cluster: [ - 'manage_security', - 'manage_api_key', - ], - }, - }], - ], - statusCode: 200, - result: { - areApiKeysEnabled: false, - isAdmin: true, - }, - }, - }); - - getPrivilegesTest('returns isAdmin=false when user has insufficient privileges', { - callWithRequestImpls: [ - async () => ({ - username: 'elastic', - has_all_requested: true, - cluster: { manage_api_key: false, manage_security: false }, - index: {}, - application: {} - }), - async () => ( - { - api_keys: - [{ - id: 'si8If24B1bKsmSLTAhJV', - name: 'my-api-key', - creation: 1574089261632, - expiration: 1574175661632, - invalidated: false, - username: 'elastic', - realm: 'reserved' - }] - } - ), - ], - asserts: { - callWithRequests: [ - ['shield.getAPIKeys', { owner: true }], - ['shield.hasPrivileges', { - body: { - cluster: [ - 'manage_security', - 'manage_api_key', - ], - }, - }], - ], - statusCode: 200, - result: { - areApiKeysEnabled: true, - isAdmin: false, - }, - }, - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js deleted file mode 100644 index f37c9a2fd917f..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js +++ /dev/null @@ -1,227 +0,0 @@ -/* - * 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 Boom from 'boom'; -import Joi from 'joi'; -import { schema } from '@kbn/config-schema'; -import { canRedirectRequest, wrapError, OIDCAuthenticationFlow } from '../../../../../../../plugins/security/server'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; -import { createCSPRuleString } from '../../../../../../../../src/legacy/server/csp'; - -export function initAuthenticateApi({ authc: { login, logout }, __legacyCompat: { config } }, server) { - function prepareCustomResourceResponse(response, contentType) { - return response - .header('cache-control', 'private, no-cache, no-store') - .header('content-security-policy', createCSPRuleString(server.config().get('csp.rules'))) - .type(contentType); - } - - server.route({ - method: 'POST', - path: '/api/security/v1/login', - config: { - auth: false, - validate: { - payload: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required() - }) - }, - response: { - emptyStatusCode: 204, - } - }, - async handler(request, h) { - const { username, password } = request.payload; - - try { - // We should prefer `token` over `basic` if possible. - const providerToLoginWith = config.authc.providers.includes('token') - ? 'token' - : 'basic'; - const authenticationResult = await login(KibanaRequest.from(request), { - provider: providerToLoginWith, - value: { username, password } - }); - - if (!authenticationResult.succeeded()) { - throw Boom.unauthorized(authenticationResult.error); - } - - return h.response(); - } catch(err) { - throw wrapError(err); - } - } - }); - - /** - * The route should be configured as a redirect URI in OP when OpenID Connect implicit flow - * is used, so that we can extract authentication response from URL fragment and send it to - * the `/api/security/v1/oidc` route. - */ - server.route({ - method: 'GET', - path: '/api/security/v1/oidc/implicit', - config: { auth: false }, - async handler(request, h) { - return prepareCustomResourceResponse( - h.response(` - - Kibana OpenID Connect Login - - `), - 'text/html' - ); - } - }); - - /** - * The route that accompanies `/api/security/v1/oidc/implicit` and renders a JavaScript snippet - * that extracts fragment part from the URL and send it to the `/api/security/v1/oidc` route. - * We need this separate endpoint because of default CSP policy that forbids inline scripts. - */ - server.route({ - method: 'GET', - path: '/api/security/v1/oidc/implicit.js', - config: { auth: false }, - async handler(request, h) { - return prepareCustomResourceResponse( - h.response(` - window.location.replace( - '${server.config().get('server.basePath')}/api/security/v1/oidc?authenticationResponseURI=' + - encodeURIComponent(window.location.href) - ); - `), - 'text/javascript' - ); - } - }); - - server.route({ - // POST is only allowed for Third Party initiated authentication - // Consider splitting this route into two (GET and POST) when it's migrated to New Platform. - method: ['GET', 'POST'], - path: '/api/security/v1/oidc', - config: { - auth: false, - validate: { - query: Joi.object().keys({ - iss: Joi.string().uri({ scheme: 'https' }), - login_hint: Joi.string(), - target_link_uri: Joi.string().uri(), - code: Joi.string(), - error: Joi.string(), - error_description: Joi.string(), - error_uri: Joi.string().uri(), - state: Joi.string(), - authenticationResponseURI: Joi.string(), - }).unknown(), - } - }, - async handler(request, h) { - try { - const query = request.query || {}; - const payload = request.payload || {}; - - // An HTTP GET request with a query parameter named `authenticationResponseURI` that includes URL fragment OpenID - // Connect Provider sent during implicit authentication flow to the Kibana own proxy page that extracted that URL - // fragment and put it into `authenticationResponseURI` query string parameter for this endpoint. See more details - // at https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth - let loginAttempt; - if (query.authenticationResponseURI) { - loginAttempt = { - flow: OIDCAuthenticationFlow.Implicit, - authenticationResponseURI: query.authenticationResponseURI, - }; - } else if (query.code || query.error) { - // An HTTP GET request with a query parameter named `code` (or `error`) as the response to a successful (or - // failed) authentication from an OpenID Connect Provider during authorization code authentication flow. - // See more details at https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth. - loginAttempt = { - flow: OIDCAuthenticationFlow.AuthorizationCode, - // We pass the path only as we can't be sure of the full URL and Elasticsearch doesn't need it anyway. - authenticationResponseURI: request.url.path, - }; - } else if (query.iss || payload.iss) { - // An HTTP GET request with a query parameter named `iss` or an HTTP POST request with the same parameter in the - // payload as part of a 3rd party initiated authentication. See more details at - // https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin - loginAttempt = { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, - iss: query.iss || payload.iss, - loginHint: query.login_hint || payload.login_hint, - }; - } - - if (!loginAttempt) { - throw Boom.badRequest('Unrecognized login attempt.'); - } - - // We handle the fact that the user might get redirected to Kibana while already having an session - // Return an error notifying the user they are already logged in. - const authenticationResult = await login(KibanaRequest.from(request), { - provider: 'oidc', - value: loginAttempt - }); - if (authenticationResult.succeeded()) { - return Boom.forbidden( - 'Sorry, you already have an active Kibana session. ' + - 'If you want to start a new one, please logout from the existing session first.' - ); - } - - if (authenticationResult.redirected()) { - return h.redirect(authenticationResult.redirectURL); - } - - throw Boom.unauthorized(authenticationResult.error); - } catch (err) { - throw wrapError(err); - } - } - }); - - server.route({ - method: 'GET', - path: '/api/security/v1/logout', - config: { - auth: false - }, - async handler(request, h) { - if (!canRedirectRequest(KibanaRequest.from(request))) { - throw Boom.badRequest('Client should be able to process redirect response.'); - } - - try { - const deauthenticationResult = await logout( - // Allow unknown query parameters as this endpoint can be hit by the 3rd-party with any - // set of query string parameters (e.g. SAML/OIDC logout request parameters). - KibanaRequest.from(request, { - query: schema.object({}, { allowUnknowns: true }), - }) - ); - if (deauthenticationResult.failed()) { - throw wrapError(deauthenticationResult.error); - } - - return h.redirect( - deauthenticationResult.redirectURL || `${server.config().get('server.basePath')}/` - ); - } catch (err) { - throw wrapError(err); - } - } - }); - - server.route({ - method: 'GET', - path: '/api/security/v1/me', - handler(request) { - return request.auth.credentials; - } - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js b/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js deleted file mode 100644 index 7265b83783fdd..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 _ from 'lodash'; -import { getClient } from '../../../../../../server/lib/get_client_shield'; -import { wrapError } from '../../../../../../../plugins/security/server'; - -export function initIndicesApi(server) { - const callWithRequest = getClient(server).callWithRequest; - - server.route({ - method: 'GET', - path: '/api/security/v1/fields/{query}', - handler(request) { - return callWithRequest(request, 'indices.getFieldMapping', { - index: request.params.query, - fields: '*', - allowNoIndices: false, - includeDefaults: true - }) - .then((mappings) => - _(mappings) - .map('mappings') - .flatten() - .map(_.keys) - .flatten() - .uniq() - .value() - ) - .catch(wrapError); - } - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js deleted file mode 100644 index d6dc39da657b1..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js +++ /dev/null @@ -1,142 +0,0 @@ -/* - * 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 _ from 'lodash'; -import Boom from 'boom'; -import Joi from 'joi'; -import { getClient } from '../../../../../../server/lib/get_client_shield'; -import { userSchema } from '../../../lib/user_schema'; -import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; -import { wrapError } from '../../../../../../../plugins/security/server'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; - -export function initUsersApi({ authc: { login }, __legacyCompat: { config } }, server) { - const callWithRequest = getClient(server).callWithRequest; - const routePreCheckLicenseFn = routePreCheckLicense(server); - - server.route({ - method: 'GET', - path: '/api/security/v1/users', - handler(request) { - return callWithRequest(request, 'shield.getUser').then( - _.values, - wrapError - ); - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); - - server.route({ - method: 'GET', - path: '/api/security/v1/users/{username}', - handler(request) { - const username = request.params.username; - return callWithRequest(request, 'shield.getUser', { username }).then( - (response) => { - if (response[username]) return response[username]; - throw Boom.notFound(); - }, - wrapError); - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); - - server.route({ - method: 'POST', - path: '/api/security/v1/users/{username}', - handler(request) { - const username = request.params.username; - const body = _(request.payload).omit(['username', 'enabled']).omit(_.isNull); - return callWithRequest(request, 'shield.putUser', { username, body }).then( - () => request.payload, - wrapError); - }, - config: { - validate: { - payload: userSchema - }, - pre: [routePreCheckLicenseFn] - } - }); - - server.route({ - method: 'DELETE', - path: '/api/security/v1/users/{username}', - handler(request, h) { - const username = request.params.username; - return callWithRequest(request, 'shield.deleteUser', { username }).then( - () => h.response().code(204), - wrapError); - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); - - server.route({ - method: 'POST', - path: '/api/security/v1/users/{username}/password', - async handler(request, h) { - const username = request.params.username; - const { password, newPassword } = request.payload; - const isCurrentUser = username === request.auth.credentials.username; - - // We should prefer `token` over `basic` if possible. - const providerToLoginWith = config.authc.providers.includes('token') - ? 'token' - : 'basic'; - - // If user tries to change own password, let's check if old password is valid first by trying - // to login. - if (isCurrentUser) { - const authenticationResult = await login(KibanaRequest.from(request), { - provider: providerToLoginWith, - value: { username, password }, - // We shouldn't alter authentication state just yet. - stateless: true, - }); - - if (!authenticationResult.succeeded()) { - return Boom.forbidden(authenticationResult.error); - } - } - - try { - const body = { password: newPassword }; - await callWithRequest(request, 'shield.changePassword', { username, body }); - - // Now we authenticate user with the new password again updating current session if any. - if (isCurrentUser) { - const authenticationResult = await login(KibanaRequest.from(request), { - provider: providerToLoginWith, - value: { username, password: newPassword } - }); - - if (!authenticationResult.succeeded()) { - return Boom.unauthorized((authenticationResult.error)); - } - } - } catch(err) { - return wrapError(err); - } - - return h.response().code(204); - }, - config: { - validate: { - payload: Joi.object({ - password: Joi.string(), - newPassword: Joi.string().required() - }) - }, - pre: [routePreCheckLicenseFn] - } - }); -} diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/login/helpers.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/login/helpers.ts index 8a9477ad67901..b2b8ce7b9c000 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/login/helpers.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/login/helpers.ts @@ -39,7 +39,7 @@ const ELASTICSEARCH_PASSWORD = 'ELASTICSEARCH_PASSWORD'; /** * The Kibana server endpoint used for authentication */ -const LOGIN_API_ENDPOINT = '/api/security/v1/login'; +const LOGIN_API_ENDPOINT = '/internal/security/login'; /** * Authenticates with Kibana using, if specified, credentials specified by @@ -68,7 +68,7 @@ const credentialsProvidedByEnvironment = (): boolean => * Authenticates with Kibana by reading credentials from the * `CYPRESS_ELASTICSEARCH_USERNAME` and `CYPRESS_ELASTICSEARCH_PASSWORD` * environment variables, and POSTing the username and password directly to - * Kibana's `security/v1/login` endpoint, bypassing the login page (for speed). + * Kibana's `/internal/security/login` endpoint, bypassing the login page (for speed). */ const loginViaEnvironmentCredentials = () => { cy.log( @@ -90,7 +90,7 @@ const loginViaEnvironmentCredentials = () => { /** * Authenticates with Kibana by reading credentials from the * `kibana.dev.yml` file and POSTing the username and password directly to - * Kibana's `security/v1/login` endpoint, bypassing the login page (for speed). + * Kibana's `/internal/security/login` endpoint, bypassing the login page (for speed). */ const loginViaConfig = () => { cy.log( diff --git a/x-pack/legacy/server/lib/esjs_shield_plugin.js b/x-pack/legacy/server/lib/esjs_shield_plugin.js deleted file mode 100644 index b6252035aa321..0000000000000 --- a/x-pack/legacy/server/lib/esjs_shield_plugin.js +++ /dev/null @@ -1,579 +0,0 @@ -/* - * 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. - */ - -(function (root, factory) { - if (typeof define === 'function' && define.amd) { // eslint-disable-line no-undef - define([], factory); // eslint-disable-line no-undef - } else if (typeof exports === 'object') { - module.exports = factory(); - } else { - root.ElasticsearchShield = factory(); - } -}(this, function () { - return function addShieldApi(Client, config, components) { - const ca = components.clientAction.factory; - - Client.prototype.shield = components.clientAction.namespaceFactory(); - const shield = Client.prototype.shield.prototype; - - /** - * Perform a [shield.authenticate](Retrieve details about the currently authenticated user) request - * - * @param {Object} params - An object with parameters used to carry out this action - */ - shield.authenticate = ca({ - params: {}, - url: { - fmt: '/_security/_authenticate' - } - }); - - /** - * Perform a [shield.changePassword](Change the password of a user) request - * - * @param {Object} params - An object with parameters used to carry out this action - * @param {Boolean} params.refresh - Refresh the index after performing the operation - * @param {String} params.username - The username of the user to change the password for - */ - shield.changePassword = ca({ - params: { - refresh: { - type: 'boolean' - } - }, - urls: [ - { - fmt: '/_security/user/<%=username%>/_password', - req: { - username: { - type: 'string', - required: false - } - } - }, - { - fmt: '/_security/user/_password' - } - ], - needBody: true, - method: 'POST' - }); - - /** - * Perform a [shield.clearCachedRealms](Clears the internal user caches for specified realms) request - * - * @param {Object} params - An object with parameters used to carry out this action - * @param {String} params.usernames - Comma-separated list of usernames to clear from the cache - * @param {String} params.realms - Comma-separated list of realms to clear - */ - shield.clearCachedRealms = ca({ - params: { - usernames: { - type: 'string', - required: false - } - }, - url: { - fmt: '/_security/realm/<%=realms%>/_clear_cache', - req: { - realms: { - type: 'string', - required: true - } - } - }, - method: 'POST' - }); - - /** - * Perform a [shield.clearCachedRoles](Clears the internal caches for specified roles) request - * - * @param {Object} params - An object with parameters used to carry out this action - * @param {String} params.name - Role name - */ - shield.clearCachedRoles = ca({ - params: {}, - url: { - fmt: '/_security/role/<%=name%>/_clear_cache', - req: { - name: { - type: 'string', - required: true - } - } - }, - method: 'POST' - }); - - /** - * Perform a [shield.deleteRole](Remove a role from the native shield realm) request - * - * @param {Object} params - An object with parameters used to carry out this action - * @param {Boolean} params.refresh - Refresh the index after performing the operation - * @param {String} params.name - Role name - */ - shield.deleteRole = ca({ - params: { - refresh: { - type: 'boolean' - } - }, - url: { - fmt: '/_security/role/<%=name%>', - req: { - name: { - type: 'string', - required: true - } - } - }, - method: 'DELETE' - }); - - /** - * Perform a [shield.deleteUser](Remove a user from the native shield realm) request - * - * @param {Object} params - An object with parameters used to carry out this action - * @param {Boolean} params.refresh - Refresh the index after performing the operation - * @param {String} params.username - username - */ - shield.deleteUser = ca({ - params: { - refresh: { - type: 'boolean' - } - }, - url: { - fmt: '/_security/user/<%=username%>', - req: { - username: { - type: 'string', - required: true - } - } - }, - method: 'DELETE' - }); - - /** - * Perform a [shield.getRole](Retrieve one or more roles from the native shield realm) request - * - * @param {Object} params - An object with parameters used to carry out this action - * @param {String} params.name - Role name - */ - shield.getRole = ca({ - params: {}, - urls: [ - { - fmt: '/_security/role/<%=name%>', - req: { - name: { - type: 'string', - required: false - } - } - }, - { - fmt: '/_security/role' - } - ] - }); - - /** - * Perform a [shield.getUser](Retrieve one or more users from the native shield realm) request - * - * @param {Object} params - An object with parameters used to carry out this action - * @param {String, String[], Boolean} params.username - A comma-separated list of usernames - */ - shield.getUser = ca({ - params: {}, - urls: [ - { - fmt: '/_security/user/<%=username%>', - req: { - username: { - type: 'list', - required: false - } - } - }, - { - fmt: '/_security/user' - } - ] - }); - - /** - * Perform a [shield.putRole](Update or create a role for the native shield realm) request - * - * @param {Object} params - An object with parameters used to carry out this action - * @param {Boolean} params.refresh - Refresh the index after performing the operation - * @param {String} params.name - Role name - */ - shield.putRole = ca({ - params: { - refresh: { - type: 'boolean' - } - }, - url: { - fmt: '/_security/role/<%=name%>', - req: { - name: { - type: 'string', - required: true - } - } - }, - needBody: true, - method: 'PUT' - }); - - /** - * Perform a [shield.putUser](Update or create a user for the native shield realm) request - * - * @param {Object} params - An object with parameters used to carry out this action - * @param {Boolean} params.refresh - Refresh the index after performing the operation - * @param {String} params.username - The username of the User - */ - shield.putUser = ca({ - params: { - refresh: { - type: 'boolean' - } - }, - url: { - fmt: '/_security/user/<%=username%>', - req: { - username: { - type: 'string', - required: true - } - } - }, - needBody: true, - method: 'PUT' - }); - - /** - * Perform a [shield.getUserPrivileges](Retrieve a user's list of privileges) request - * - */ - shield.getUserPrivileges = ca({ - params: {}, - urls: [ - { - fmt: '/_security/user/_privileges' - } - ] - }); - - /** - * Asks Elasticsearch to prepare SAML authentication request to be sent to - * the 3rd-party SAML identity provider. - * - * @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL - * in the Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch - * will choose the right SAML realm. - * - * @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request. - * - * @returns {{realm: string, id: string, redirect: string}} Object that includes identifier - * of the SAML realm used to prepare authentication request, encrypted request token to be - * sent to Elasticsearch with SAML response and redirect URL to the identity provider that - * will be used to authenticate user. - */ - shield.samlPrepare = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/prepare' - } - }); - - /** - * Sends SAML response returned by identity provider to Elasticsearch for validation. - * - * @param {Array.} ids A list of encrypted request tokens returned within SAML - * preparation response. - * @param {string} content SAML response returned by identity provider. - * @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm - * that should be used to authenticate request. - * - * @returns {{username: string, access_token: string, expires_in: number}} Object that - * includes name of the user, access token to use for any consequent requests that - * need to be authenticated and a number of seconds after which access token will expire. - */ - shield.samlAuthenticate = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/authenticate' - } - }); - - /** - * Invalidates SAML access token. - * - * @param {string} token SAML access token that needs to be invalidated. - * - * @returns {{redirect?: string}} - */ - shield.samlLogout = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/logout' - } - }); - - /** - * Invalidates SAML session based on Logout Request received from the Identity Provider. - * - * @param {string} queryString URL encoded query string provided by Identity Provider. - * @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL in the - * Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch - * will choose the right SAML realm to invalidate session. - * @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request. - * - * @returns {{redirect?: string}} - */ - shield.samlInvalidate = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/invalidate' - } - }); - - /** - * Asks Elasticsearch to prepare an OpenID Connect authentication request to be sent to - * the 3rd-party OpenID Connect provider. - * - * @param {string} realm The OpenID Connect realm name in Elasticsearch - * - * @returns {{state: string, nonce: string, redirect: string}} Object that includes two opaque parameters that need - * to be sent to Elasticsearch with the OpenID Connect response and redirect URL to the OpenID Connect provider that - * will be used to authenticate user. - */ - shield.oidcPrepare = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oidc/prepare' - } - }); - - /** - * Sends the URL to which the OpenID Connect Provider redirected the UA to Elasticsearch for validation. - * - * @param {string} state The state parameter that was returned by Elasticsearch in the - * preparation response. - * @param {string} nonce The nonce parameter that was returned by Elasticsearch in the - * preparation response. - * @param {string} redirect_uri The URL to where the UA was redirected by the OpenID Connect provider. - * @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm - * that should be used to authenticate request. - * - * @returns {{username: string, access_token: string, refresh_token; string, expires_in: number}} Object that - * includes name of the user, access token to use for any consequent requests that - * need to be authenticated and a number of seconds after which access token will expire. - */ - shield.oidcAuthenticate = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oidc/authenticate' - } - }); - - /** - * Invalidates an access token and refresh token pair that was generated after an OpenID Connect authentication. - * - * @param {string} token An access token that was created by authenticating to an OpenID Connect realm and - * that needs to be invalidated. - * @param {string} refresh_token A refresh token that was created by authenticating to an OpenID Connect realm and - * that needs to be invalidated. - * - * @returns {{redirect?: string}} If the Elasticsearch OpenID Connect realm configuration and the - * OpenID Connect provider supports RP-initiated SLO, a URL to redirect the UA - */ - shield.oidcLogout = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oidc/logout' - } - }); - - /** - * Refreshes an access token. - * - * @param {string} grant_type Currently only "refresh_token" grant type is supported. - * @param {string} refresh_token One-time refresh token that will be exchanged to the new access/refresh token pair. - * - * @returns {{access_token: string, type: string, expires_in: number, refresh_token: string}} - */ - shield.getAccessToken = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oauth2/token' - } - }); - - /** - * Invalidates an access token. - * - * @param {string} token The access token to invalidate - * - * @returns {{created: boolean}} - */ - shield.deleteAccessToken = ca({ - method: 'DELETE', - needBody: true, - params: { - token: { - type: 'string' - } - }, - url: { - fmt: '/_security/oauth2/token' - } - }); - - shield.getPrivilege = ca({ - method: 'GET', - urls: [{ - fmt: '/_security/privilege/<%=privilege%>', - req: { - privilege: { - type: 'string', - required: false - } - } - }, { - fmt: '/_security/privilege' - }] - }); - - shield.deletePrivilege = ca({ - method: 'DELETE', - urls: [{ - fmt: '/_security/privilege/<%=application%>/<%=privilege%>', - req: { - application: { - type: 'string', - required: true - }, - privilege: { - type: 'string', - required: true - } - } - }] - }); - - shield.postPrivileges = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/privilege' - } - }); - - shield.hasPrivileges = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/user/_has_privileges' - } - }); - - shield.getBuiltinPrivileges = ca({ - params: {}, - urls: [ - { - fmt: '/_security/privilege/_builtin' - } - ] - }); - - /** - * Gets API keys in Elasticsearch - * @param {boolean} owner A boolean flag that can be used to query API keys owned by the currently authenticated user. - * Defaults to false. The realm_name or username parameters cannot be specified when this parameter is set to true as - * they are assumed to be the currently authenticated ones. - */ - shield.getAPIKeys = ca({ - method: 'GET', - urls: [{ - fmt: `/_security/api_key?owner=<%=owner%>`, - req: { - owner: { - type: 'boolean', - required: true - } - } - }] - }); - - /** - * 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', - }, - }); - - /** - * 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', - }, - }); - - /** - * Gets an access token in exchange to the certificate chain for the target subject distinguished name. - * - * @param {string[]} x509_certificate_chain An ordered array of base64-encoded (Section 4 of RFC4648 - not - * base64url-encoded) DER PKIX certificate values. - * - * @returns {{access_token: string, type: string, expires_in: number}} - */ - shield.delegatePKI = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/delegate_pki', - }, - }); - }; -})); diff --git a/x-pack/legacy/server/lib/get_client_shield.ts b/x-pack/legacy/server/lib/get_client_shield.ts deleted file mode 100644 index 1f68c2e6d3466..0000000000000 --- a/x-pack/legacy/server/lib/get_client_shield.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * 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 { once } from 'lodash'; -import { Legacy } from 'kibana'; -// @ts-ignore -import esShield from './esjs_shield_plugin'; - -export const getClient = once((server: Legacy.Server) => { - return server.plugins.elasticsearch.createCluster('security', { plugins: [esShield] }); -}); diff --git a/x-pack/legacy/plugins/security/common/model/api_key.ts b/x-pack/plugins/security/common/model/api_key.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/api_key.ts rename to x-pack/plugins/security/common/model/api_key.ts diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index c6ccd2518d261..226ea3b70afe2 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { ApiKey, ApiKeyToInvalidate } from './api_key'; export { User, EditUser, getUserDisplayName } from './user'; export { AuthenticatedUser, canUserChangePassword } from './authenticated_user'; export { BuiltinESPrivileges } from './builtin_es_privileges'; diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index c1d7dcca4c78f..ad7eab76db088 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -42,7 +42,7 @@ describe('OIDCAuthenticationProvider', () => { describe('`login` method', () => { it('redirects third party initiated login attempts to the OpenId Connect Provider.', async () => { - const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' }); + const request = httpServerMock.createKibanaRequest({ path: '/api/security/oidc' }); mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({ state: 'statevalue', @@ -205,13 +205,13 @@ describe('OIDCAuthenticationProvider', () => { describe('authorization code flow', () => { defineAuthenticationFlowTests(() => ({ request: httpServerMock.createKibanaRequest({ - path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + path: '/api/security/oidc?code=somecodehere&state=somestatehere', }), attempt: { flow: OIDCAuthenticationFlow.AuthorizationCode, - authenticationResponseURI: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + authenticationResponseURI: '/api/security/oidc?code=somecodehere&state=somestatehere', }, - expectedRedirectURI: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + expectedRedirectURI: '/api/security/oidc?code=somecodehere&state=somestatehere', })); }); @@ -219,14 +219,13 @@ describe('OIDCAuthenticationProvider', () => { defineAuthenticationFlowTests(() => ({ request: httpServerMock.createKibanaRequest({ path: - '/api/security/v1/oidc?authenticationResponseURI=http://kibana/api/security/v1/oidc/implicit#id_token=sometoken', + '/api/security/oidc?authenticationResponseURI=http://kibana/api/security/oidc/implicit#id_token=sometoken', }), attempt: { flow: OIDCAuthenticationFlow.Implicit, - authenticationResponseURI: - 'http://kibana/api/security/v1/oidc/implicit#id_token=sometoken', + authenticationResponseURI: 'http://kibana/api/security/oidc/implicit#id_token=sometoken', }, - expectedRedirectURI: 'http://kibana/api/security/v1/oidc/implicit#id_token=sometoken', + expectedRedirectURI: 'http://kibana/api/security/oidc/implicit#id_token=sometoken', })); }); }); diff --git a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts new file mode 100644 index 0000000000000..60d947bd65863 --- /dev/null +++ b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts @@ -0,0 +1,576 @@ +/* + * 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. + */ + +export function elasticsearchClientPlugin(Client: any, config: unknown, components: any) { + const ca = components.clientAction.factory; + + Client.prototype.shield = components.clientAction.namespaceFactory(); + const shield = Client.prototype.shield.prototype; + + /** + * Perform a [shield.authenticate](Retrieve details about the currently authenticated user) request + * + * @param {Object} params - An object with parameters used to carry out this action + */ + shield.authenticate = ca({ + params: {}, + url: { + fmt: '/_security/_authenticate', + }, + }); + + /** + * Perform a [shield.changePassword](Change the password of a user) request + * + * @param {Object} params - An object with parameters used to carry out this action + * @param {Boolean} params.refresh - Refresh the index after performing the operation + * @param {String} params.username - The username of the user to change the password for + */ + shield.changePassword = ca({ + params: { + refresh: { + type: 'boolean', + }, + }, + urls: [ + { + fmt: '/_security/user/<%=username%>/_password', + req: { + username: { + type: 'string', + required: false, + }, + }, + }, + { + fmt: '/_security/user/_password', + }, + ], + needBody: true, + method: 'POST', + }); + + /** + * Perform a [shield.clearCachedRealms](Clears the internal user caches for specified realms) request + * + * @param {Object} params - An object with parameters used to carry out this action + * @param {String} params.usernames - Comma-separated list of usernames to clear from the cache + * @param {String} params.realms - Comma-separated list of realms to clear + */ + shield.clearCachedRealms = ca({ + params: { + usernames: { + type: 'string', + required: false, + }, + }, + url: { + fmt: '/_security/realm/<%=realms%>/_clear_cache', + req: { + realms: { + type: 'string', + required: true, + }, + }, + }, + method: 'POST', + }); + + /** + * Perform a [shield.clearCachedRoles](Clears the internal caches for specified roles) request + * + * @param {Object} params - An object with parameters used to carry out this action + * @param {String} params.name - Role name + */ + shield.clearCachedRoles = ca({ + params: {}, + url: { + fmt: '/_security/role/<%=name%>/_clear_cache', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + method: 'POST', + }); + + /** + * Perform a [shield.deleteRole](Remove a role from the native shield realm) request + * + * @param {Object} params - An object with parameters used to carry out this action + * @param {Boolean} params.refresh - Refresh the index after performing the operation + * @param {String} params.name - Role name + */ + shield.deleteRole = ca({ + params: { + refresh: { + type: 'boolean', + }, + }, + url: { + fmt: '/_security/role/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + method: 'DELETE', + }); + + /** + * Perform a [shield.deleteUser](Remove a user from the native shield realm) request + * + * @param {Object} params - An object with parameters used to carry out this action + * @param {Boolean} params.refresh - Refresh the index after performing the operation + * @param {String} params.username - username + */ + shield.deleteUser = ca({ + params: { + refresh: { + type: 'boolean', + }, + }, + url: { + fmt: '/_security/user/<%=username%>', + req: { + username: { + type: 'string', + required: true, + }, + }, + }, + method: 'DELETE', + }); + + /** + * Perform a [shield.getRole](Retrieve one or more roles from the native shield realm) request + * + * @param {Object} params - An object with parameters used to carry out this action + * @param {String} params.name - Role name + */ + shield.getRole = ca({ + params: {}, + urls: [ + { + fmt: '/_security/role/<%=name%>', + req: { + name: { + type: 'string', + required: false, + }, + }, + }, + { + fmt: '/_security/role', + }, + ], + }); + + /** + * Perform a [shield.getUser](Retrieve one or more users from the native shield realm) request + * + * @param {Object} params - An object with parameters used to carry out this action + * @param {String, String[], Boolean} params.username - A comma-separated list of usernames + */ + shield.getUser = ca({ + params: {}, + urls: [ + { + fmt: '/_security/user/<%=username%>', + req: { + username: { + type: 'list', + required: false, + }, + }, + }, + { + fmt: '/_security/user', + }, + ], + }); + + /** + * Perform a [shield.putRole](Update or create a role for the native shield realm) request + * + * @param {Object} params - An object with parameters used to carry out this action + * @param {Boolean} params.refresh - Refresh the index after performing the operation + * @param {String} params.name - Role name + */ + shield.putRole = ca({ + params: { + refresh: { + type: 'boolean', + }, + }, + url: { + fmt: '/_security/role/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + needBody: true, + method: 'PUT', + }); + + /** + * Perform a [shield.putUser](Update or create a user for the native shield realm) request + * + * @param {Object} params - An object with parameters used to carry out this action + * @param {Boolean} params.refresh - Refresh the index after performing the operation + * @param {String} params.username - The username of the User + */ + shield.putUser = ca({ + params: { + refresh: { + type: 'boolean', + }, + }, + url: { + fmt: '/_security/user/<%=username%>', + req: { + username: { + type: 'string', + required: true, + }, + }, + }, + needBody: true, + method: 'PUT', + }); + + /** + * Perform a [shield.getUserPrivileges](Retrieve a user's list of privileges) request + * + */ + shield.getUserPrivileges = ca({ + params: {}, + urls: [ + { + fmt: '/_security/user/_privileges', + }, + ], + }); + + /** + * Asks Elasticsearch to prepare SAML authentication request to be sent to + * the 3rd-party SAML identity provider. + * + * @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL + * in the Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch + * will choose the right SAML realm. + * + * @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request. + * + * @returns {{realm: string, id: string, redirect: string}} Object that includes identifier + * of the SAML realm used to prepare authentication request, encrypted request token to be + * sent to Elasticsearch with SAML response and redirect URL to the identity provider that + * will be used to authenticate user. + */ + shield.samlPrepare = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/saml/prepare', + }, + }); + + /** + * Sends SAML response returned by identity provider to Elasticsearch for validation. + * + * @param {Array.} ids A list of encrypted request tokens returned within SAML + * preparation response. + * @param {string} content SAML response returned by identity provider. + * @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm + * that should be used to authenticate request. + * + * @returns {{username: string, access_token: string, expires_in: number}} Object that + * includes name of the user, access token to use for any consequent requests that + * need to be authenticated and a number of seconds after which access token will expire. + */ + shield.samlAuthenticate = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/saml/authenticate', + }, + }); + + /** + * Invalidates SAML access token. + * + * @param {string} token SAML access token that needs to be invalidated. + * + * @returns {{redirect?: string}} + */ + shield.samlLogout = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/saml/logout', + }, + }); + + /** + * Invalidates SAML session based on Logout Request received from the Identity Provider. + * + * @param {string} queryString URL encoded query string provided by Identity Provider. + * @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL in the + * Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch + * will choose the right SAML realm to invalidate session. + * @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request. + * + * @returns {{redirect?: string}} + */ + shield.samlInvalidate = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/saml/invalidate', + }, + }); + + /** + * Asks Elasticsearch to prepare an OpenID Connect authentication request to be sent to + * the 3rd-party OpenID Connect provider. + * + * @param {string} realm The OpenID Connect realm name in Elasticsearch + * + * @returns {{state: string, nonce: string, redirect: string}} Object that includes two opaque parameters that need + * to be sent to Elasticsearch with the OpenID Connect response and redirect URL to the OpenID Connect provider that + * will be used to authenticate user. + */ + shield.oidcPrepare = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/oidc/prepare', + }, + }); + + /** + * Sends the URL to which the OpenID Connect Provider redirected the UA to Elasticsearch for validation. + * + * @param {string} state The state parameter that was returned by Elasticsearch in the + * preparation response. + * @param {string} nonce The nonce parameter that was returned by Elasticsearch in the + * preparation response. + * @param {string} redirect_uri The URL to where the UA was redirected by the OpenID Connect provider. + * @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm + * that should be used to authenticate request. + * + * @returns {{username: string, access_token: string, refresh_token; string, expires_in: number}} Object that + * includes name of the user, access token to use for any consequent requests that + * need to be authenticated and a number of seconds after which access token will expire. + */ + shield.oidcAuthenticate = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/oidc/authenticate', + }, + }); + + /** + * Invalidates an access token and refresh token pair that was generated after an OpenID Connect authentication. + * + * @param {string} token An access token that was created by authenticating to an OpenID Connect realm and + * that needs to be invalidated. + * @param {string} refresh_token A refresh token that was created by authenticating to an OpenID Connect realm and + * that needs to be invalidated. + * + * @returns {{redirect?: string}} If the Elasticsearch OpenID Connect realm configuration and the + * OpenID Connect provider supports RP-initiated SLO, a URL to redirect the UA + */ + shield.oidcLogout = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/oidc/logout', + }, + }); + + /** + * Refreshes an access token. + * + * @param {string} grant_type Currently only "refresh_token" grant type is supported. + * @param {string} refresh_token One-time refresh token that will be exchanged to the new access/refresh token pair. + * + * @returns {{access_token: string, type: string, expires_in: number, refresh_token: string}} + */ + shield.getAccessToken = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/oauth2/token', + }, + }); + + /** + * Invalidates an access token. + * + * @param {string} token The access token to invalidate + * + * @returns {{created: boolean}} + */ + shield.deleteAccessToken = ca({ + method: 'DELETE', + needBody: true, + params: { + token: { + type: 'string', + }, + }, + url: { + fmt: '/_security/oauth2/token', + }, + }); + + shield.getPrivilege = ca({ + method: 'GET', + urls: [ + { + fmt: '/_security/privilege/<%=privilege%>', + req: { + privilege: { + type: 'string', + required: false, + }, + }, + }, + { + fmt: '/_security/privilege', + }, + ], + }); + + shield.deletePrivilege = ca({ + method: 'DELETE', + urls: [ + { + fmt: '/_security/privilege/<%=application%>/<%=privilege%>', + req: { + application: { + type: 'string', + required: true, + }, + privilege: { + type: 'string', + required: true, + }, + }, + }, + ], + }); + + shield.postPrivileges = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/privilege', + }, + }); + + shield.hasPrivileges = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/user/_has_privileges', + }, + }); + + shield.getBuiltinPrivileges = ca({ + params: {}, + urls: [ + { + fmt: '/_security/privilege/_builtin', + }, + ], + }); + + /** + * Gets API keys in Elasticsearch + * @param {boolean} owner A boolean flag that can be used to query API keys owned by the currently authenticated user. + * Defaults to false. The realm_name or username parameters cannot be specified when this parameter is set to true as + * they are assumed to be the currently authenticated ones. + */ + shield.getAPIKeys = ca({ + method: 'GET', + urls: [ + { + fmt: `/_security/api_key?owner=<%=owner%>`, + req: { + owner: { + type: 'boolean', + required: true, + }, + }, + }, + ], + }); + + /** + * 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', + }, + }); + + /** + * 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', + }, + }); + + /** + * Gets an access token in exchange to the certificate chain for the target subject distinguished name. + * + * @param {string[]} x509_certificate_chain An ordered array of base64-encoded (Section 4 of RFC4648 - not + * base64url-encoded) DER PKIX certificate values. + * + * @returns {{access_token: string, type: string, expires_in: number}} + */ + shield.delegatePKI = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/delegate_pki', + }, + }); +} diff --git a/x-pack/plugins/security/server/errors.ts b/x-pack/plugins/security/server/errors.ts index e0c2918991696..b5f3667558f55 100644 --- a/x-pack/plugins/security/server/errors.ts +++ b/x-pack/plugins/security/server/errors.ts @@ -5,11 +5,25 @@ */ import Boom from 'boom'; +import { CustomHttpResponseOptions, ResponseError } from '../../../../src/core/server'; export function wrapError(error: any) { return Boom.boomify(error, { statusCode: getErrorStatusCode(error) }); } +/** + * Wraps error into error suitable for Core's custom error response. + * @param error Any error instance. + */ +export function wrapIntoCustomErrorResponse(error: any) { + const wrappedError = wrapError(error); + return { + body: wrappedError, + headers: wrappedError.output.headers, + statusCode: wrappedError.output.statusCode, + } as CustomHttpResponseOptions; +} + /** * Extracts error code from Boom and Elasticsearch "native" errors. * @param error Error instance to extract status code from. diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index ec43bbd95901a..e72e94e9cd94b 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -9,18 +9,8 @@ import { ConfigSchema } from './config'; import { Plugin } from './plugin'; // These exports are part of public Security plugin contract, any change in signature of exported -// functions or removal of exports should be considered as a breaking change. Ideally we should -// reduce number of such exports to zero and provide everything we want to expose via Setup/Start -// run-time contracts. -export { wrapError } from './errors'; -export { - canRedirectRequest, - AuthenticationResult, - DeauthenticationResult, - OIDCAuthenticationFlow, - CreateAPIKeyResult, -} from './authentication'; - +// functions or removal of exports should be considered as a breaking change. +export { AuthenticationResult, DeauthenticationResult, CreateAPIKeyResult } from './authentication'; export { PluginSetupContract } from './plugin'; export const config = { schema: ConfigSchema }; diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 26788c3ef9230..0569f5f4de3a6 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -7,6 +7,7 @@ import { of } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; import { IClusterClient, CoreSetup } from '../../../../src/core/server'; +import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; import { Plugin, PluginSetupDependencies } from './plugin'; import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; @@ -48,12 +49,6 @@ describe('Security Plugin', () => { Object { "__legacyCompat": Object { "config": Object { - "authc": Object { - "providers": Array [ - "saml", - "token", - ], - }, "cookieName": "sid", "loginAssistanceMessage": undefined, "secureCookies": true, @@ -115,7 +110,7 @@ describe('Security Plugin', () => { expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledTimes(1); expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledWith('security', { - plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')], + plugins: [elasticsearchClientPlugin], }); }); }); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index cb197ecaf7e10..857a77392f0c7 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -28,6 +28,7 @@ import { defineRoutes } from './routes'; import { SecurityLicenseService, SecurityLicense } from './licensing'; import { setupSavedObjects } from './saved_objects'; import { SecurityAuditLogger } from './audit'; +import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; export type SpacesService = Pick< SpacesPluginSetup['spacesService'], @@ -77,7 +78,8 @@ export interface PluginSetupContract { lifespan: number | null; }; secureCookies: boolean; - authc: { providers: string[] }; + cookieName: string; + loginAssistanceMessage: string; }>; }; } @@ -126,7 +128,7 @@ export class Plugin { .toPromise(); this.clusterClient = core.elasticsearch.createClient('security', { - plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')], + plugins: [elasticsearchClientPlugin], }); const { license, update: updateLicense } = new SecurityLicenseService().setup(); @@ -211,7 +213,6 @@ export class Plugin { }, secureCookies: config.secureCookies, cookieName: config.cookieName, - authc: { providers: config.authc.providers }, }, }, }); diff --git a/x-pack/plugins/security/server/routes/api_keys/get.test.ts b/x-pack/plugins/security/server/routes/api_keys/get.test.ts new file mode 100644 index 0000000000000..2b2283edea2e8 --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/get.test.ts @@ -0,0 +1,160 @@ +/* + * 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 { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE, LicenseCheck } from '../../../../licensing/server'; +import { defineGetApiKeysRoutes } from './get'; + +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../index.mock'; +import Boom from 'boom'; + +interface TestOptions { + isAdmin?: boolean; + licenseCheckResult?: LicenseCheck; + apiResponse?: () => Promise; + asserts: { statusCode: number; result?: Record }; +} + +describe('Get API keys', () => { + const getApiKeysTest = ( + description: string, + { + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, + apiResponse, + asserts, + isAdmin = true, + }: TestOptions + ) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + if (apiResponse) { + mockScopedClusterClient.callAsCurrentUser.mockImplementation(apiResponse); + } + + defineGetApiKeysRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/internal/security/api_key', + query: { isAdmin: isAdmin.toString() }, + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + if (apiResponse) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.getAPIKeys', + { owner: !isAdmin } + ); + } else { + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); + } + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + }; + + describe('failure', () => { + getApiKeysTest('returns result of license checker', { + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, + }); + + const error = Boom.notAcceptable('test not acceptable message'); + getApiKeysTest('returns error from cluster client', { + apiResponse: async () => { + throw error; + }, + asserts: { statusCode: 406, result: error }, + }); + }); + + describe('success', () => { + getApiKeysTest('returns API keys', { + apiResponse: async () => ({ + api_keys: [ + { + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved', + }, + ], + }), + asserts: { + statusCode: 200, + result: { + apiKeys: [ + { + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved', + }, + ], + }, + }, + }); + getApiKeysTest('returns only valid API keys', { + apiResponse: async () => ({ + api_keys: [ + { + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key1', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: true, + username: 'elastic', + realm: 'reserved', + }, + { + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key2', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved', + }, + ], + }), + asserts: { + statusCode: 200, + result: { + apiKeys: [ + { + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key2', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved', + }, + ], + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/api_keys/get.ts b/x-pack/plugins/security/server/routes/api_keys/get.ts new file mode 100644 index 0000000000000..6e98b4b098405 --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/get.ts @@ -0,0 +1,43 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { ApiKey } from '../../../common/model'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { RouteDefinitionParams } from '..'; + +export function defineGetApiKeysRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/api_key', + validate: { + query: schema.object({ + // We don't use `schema.boolean` here, because all query string parameters are treated as + // strings and @kbn/config-schema doesn't coerce strings to booleans. + // + // A boolean flag that can be used to query API keys owned by the currently authenticated + // user. `false` means that only API keys of currently authenticated user will be returned. + isAdmin: schema.oneOf([schema.literal('true'), schema.literal('false')]), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const isAdmin = request.query.isAdmin === 'true'; + const { api_keys: apiKeys } = (await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getAPIKeys', { owner: !isAdmin })) as { api_keys: ApiKey[] }; + + const validKeys = apiKeys.filter(({ invalidated }) => !invalidated); + + return response.ok({ body: { apiKeys: validKeys } }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/api_keys/index.ts b/x-pack/plugins/security/server/routes/api_keys/index.ts new file mode 100644 index 0000000000000..d75eb1bcbe961 --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { defineGetApiKeysRoutes } from './get'; +import { defineCheckPrivilegesRoutes } from './privileges'; +import { defineInvalidateApiKeysRoutes } from './invalidate'; +import { RouteDefinitionParams } from '..'; + +export function defineApiKeysRoutes(params: RouteDefinitionParams) { + defineGetApiKeysRoutes(params); + defineCheckPrivilegesRoutes(params); + defineInvalidateApiKeysRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/api_keys/invalidate.test.ts b/x-pack/plugins/security/server/routes/api_keys/invalidate.test.ts new file mode 100644 index 0000000000000..4ea21bda5f743 --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/invalidate.test.ts @@ -0,0 +1,220 @@ +/* + * 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 Boom from 'boom'; +import { Type } from '@kbn/config-schema'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE, LicenseCheck } from '../../../../licensing/server'; +import { defineInvalidateApiKeysRoutes } from './invalidate'; + +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../index.mock'; + +interface TestOptions { + licenseCheckResult?: LicenseCheck; + apiResponses?: Array<() => Promise>; + payload?: Record; + asserts: { statusCode: number; result?: Record; apiArguments?: unknown[][] }; +} + +describe('Invalidate API keys', () => { + const postInvalidateTest = ( + description: string, + { + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, + apiResponses = [], + asserts, + payload, + }: TestOptions + ) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + for (const apiResponse of apiResponses) { + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(apiResponse); + } + + defineInvalidateApiKeysRoutes(mockRouteDefinitionParams); + const [[{ validate }, handler]] = mockRouteDefinitionParams.router.post.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'post', + path: '/internal/security/api_key/invalidate', + body: payload !== undefined ? (validate as any).body.validate(payload) : undefined, + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + if (Array.isArray(asserts.apiArguments)) { + for (const apiArguments of asserts.apiArguments) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith( + mockRequest + ); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(...apiArguments); + } + } else { + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); + } + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + }; + + describe('request validation', () => { + let requestBodySchema: Type; + beforeEach(() => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + defineInvalidateApiKeysRoutes(mockRouteDefinitionParams); + + const [[{ validate }]] = mockRouteDefinitionParams.router.post.mock.calls; + requestBodySchema = (validate as any).body; + }); + + test('requires both isAdmin and apiKeys parameters', () => { + expect(() => + requestBodySchema.validate({}, {}, 'request body') + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.apiKeys]: expected value of type [array] but got [undefined]"` + ); + + expect(() => + requestBodySchema.validate({ apiKeys: [] }, {}, 'request body') + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.isAdmin]: expected value of type [boolean] but got [undefined]"` + ); + + expect(() => + requestBodySchema.validate({ apiKeys: {}, isAdmin: true }, {}, 'request body') + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.apiKeys]: expected value of type [array] but got [Object]"` + ); + + expect(() => + requestBodySchema.validate( + { + apiKeys: [{ id: 'some-id', name: 'some-name', unknown: 'some-unknown' }], + isAdmin: true, + }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.apiKeys.0.unknown]: definition for this key is missing"` + ); + }); + }); + + describe('failure', () => { + postInvalidateTest('returns result of license checker', { + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, + payload: { apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], isAdmin: true }, + asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, + }); + + const error = Boom.notAcceptable('test not acceptable message'); + postInvalidateTest('returns error from cluster client', { + apiResponses: [ + async () => { + throw error; + }, + ], + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], + isAdmin: true, + }, + asserts: { + apiArguments: [['shield.invalidateAPIKey', { body: { id: 'si8If24B1bKsmSLTAhJV' } }]], + statusCode: 200, + result: { + itemsInvalidated: [], + errors: [ + { + id: 'si8If24B1bKsmSLTAhJV', + name: 'my-api-key', + error: Boom.notAcceptable('test not acceptable message'), + }, + ], + }, + }, + }); + }); + + describe('success', () => { + postInvalidateTest('invalidates API keys', { + apiResponses: [async () => null], + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], + isAdmin: true, + }, + asserts: { + apiArguments: [['shield.invalidateAPIKey', { body: { id: 'si8If24B1bKsmSLTAhJV' } }]], + statusCode: 200, + result: { + itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], + errors: [], + }, + }, + }); + + postInvalidateTest('adds "owner" to body if isAdmin=false', { + apiResponses: [async () => null], + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], + isAdmin: false, + }, + asserts: { + apiArguments: [ + ['shield.invalidateAPIKey', { body: { id: 'si8If24B1bKsmSLTAhJV', owner: true } }], + ], + statusCode: 200, + result: { + itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], + errors: [], + }, + }, + }); + + postInvalidateTest('returns only successful invalidation requests', { + apiResponses: [ + async () => null, + async () => { + throw Boom.notAcceptable('test not acceptable message'); + }, + ], + payload: { + apiKeys: [ + { id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' }, + { id: 'ab8If24B1bKsmSLTAhNC', name: 'my-api-key2' }, + ], + isAdmin: true, + }, + asserts: { + apiArguments: [ + ['shield.invalidateAPIKey', { body: { id: 'si8If24B1bKsmSLTAhJV' } }], + ['shield.invalidateAPIKey', { body: { id: 'ab8If24B1bKsmSLTAhNC' } }], + ], + statusCode: 200, + result: { + itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' }], + errors: [ + { + id: 'ab8If24B1bKsmSLTAhNC', + name: 'my-api-key2', + error: Boom.notAcceptable('test not acceptable message'), + }, + ], + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/api_keys/invalidate.ts b/x-pack/plugins/security/server/routes/api_keys/invalidate.ts new file mode 100644 index 0000000000000..cb86c1024ae9a --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/invalidate.ts @@ -0,0 +1,69 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { ApiKey } from '../../../common/model'; +import { wrapError, wrapIntoCustomErrorResponse } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +interface ResponseType { + itemsInvalidated: Array>; + errors: Array & { error: Error }>; +} + +export function defineInvalidateApiKeysRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/api_key/invalidate', + validate: { + body: schema.object({ + apiKeys: schema.arrayOf(schema.object({ id: schema.string(), name: schema.string() })), + isAdmin: schema.boolean(), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const scopedClusterClient = clusterClient.asScoped(request); + + // Invalidate all API keys in parallel. + const invalidationResult = ( + await Promise.all( + request.body.apiKeys.map(async key => { + try { + const body: { id: string; owner?: boolean } = { id: key.id }; + if (!request.body.isAdmin) { + body.owner = true; + } + + // Send the request to invalidate the API key and return an error if it could not be deleted. + await scopedClusterClient.callAsCurrentUser('shield.invalidateAPIKey', { body }); + return { key, error: undefined }; + } catch (error) { + return { key, error: wrapError(error) }; + } + }) + ) + ).reduce( + (responseBody, { key, error }) => { + if (error) { + responseBody.errors.push({ ...key, error }); + } else { + responseBody.itemsInvalidated.push(key); + } + return responseBody; + }, + { itemsInvalidated: [], errors: [] } as ResponseType + ); + + return response.ok({ body: invalidationResult }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/api_keys/privileges.test.ts b/x-pack/plugins/security/server/routes/api_keys/privileges.test.ts new file mode 100644 index 0000000000000..866e455063bdc --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/privileges.test.ts @@ -0,0 +1,187 @@ +/* + * 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 Boom from 'boom'; +import { LICENSE_CHECK_STATE, LicenseCheck } from '../../../../licensing/server'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; + +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../index.mock'; +import { defineCheckPrivilegesRoutes } from './privileges'; + +interface TestOptions { + licenseCheckResult?: LicenseCheck; + apiResponses?: Array<() => Promise>; + asserts: { statusCode: number; result?: Record; apiArguments?: unknown[][] }; +} + +describe('Check API keys privileges', () => { + const getPrivilegesTest = ( + description: string, + { + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, + apiResponses = [], + asserts, + }: TestOptions + ) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + for (const apiResponse of apiResponses) { + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(apiResponse); + } + + defineCheckPrivilegesRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/internal/security/api_key/privileges', + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + if (Array.isArray(asserts.apiArguments)) { + for (const apiArguments of asserts.apiArguments) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith( + mockRequest + ); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(...apiArguments); + } + } else { + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); + } + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + }; + + describe('failure', () => { + getPrivilegesTest('returns result of license checker', { + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, + }); + + const error = Boom.notAcceptable('test not acceptable message'); + getPrivilegesTest('returns error from cluster client', { + apiResponses: [ + async () => { + throw error; + }, + async () => {}, + ], + asserts: { + apiArguments: [ + ['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }], + ['shield.getAPIKeys', { owner: true }], + ], + statusCode: 406, + result: error, + }, + }); + }); + + describe('success', () => { + getPrivilegesTest('returns areApiKeysEnabled and isAdmin', { + apiResponses: [ + async () => ({ + username: 'elastic', + has_all_requested: true, + cluster: { manage_api_key: true, manage_security: true }, + index: {}, + application: {}, + }), + async () => ({ + api_keys: [ + { + id: 'si8If24B1bKsmSLTAhJV', + name: 'my-api-key', + creation: 1574089261632, + expiration: 1574175661632, + invalidated: false, + username: 'elastic', + realm: 'reserved', + }, + ], + }), + ], + asserts: { + apiArguments: [ + ['shield.getAPIKeys', { owner: true }], + ['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }], + ], + statusCode: 200, + result: { areApiKeysEnabled: true, isAdmin: true }, + }, + }); + + getPrivilegesTest( + 'returns areApiKeysEnabled=false when getAPIKeys error message includes "api keys are not enabled"', + { + apiResponses: [ + async () => ({ + username: 'elastic', + has_all_requested: true, + cluster: { manage_api_key: true, manage_security: true }, + index: {}, + application: {}, + }), + async () => { + throw Boom.unauthorized('api keys are not enabled'); + }, + ], + asserts: { + apiArguments: [ + ['shield.getAPIKeys', { owner: true }], + ['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }], + ], + statusCode: 200, + result: { areApiKeysEnabled: false, isAdmin: true }, + }, + } + ); + + getPrivilegesTest('returns isAdmin=false when user has insufficient privileges', { + apiResponses: [ + async () => ({ + username: 'elastic', + has_all_requested: true, + cluster: { manage_api_key: false, manage_security: false }, + index: {}, + application: {}, + }), + async () => ({ + api_keys: [ + { + id: 'si8If24B1bKsmSLTAhJV', + name: 'my-api-key', + creation: 1574089261632, + expiration: 1574175661632, + invalidated: false, + username: 'elastic', + realm: 'reserved', + }, + ], + }), + ], + asserts: { + apiArguments: [ + ['shield.getAPIKeys', { owner: true }], + ['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }], + ], + statusCode: 200, + result: { areApiKeysEnabled: true, isAdmin: false }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/api_keys/privileges.ts b/x-pack/plugins/security/server/routes/api_keys/privileges.ts new file mode 100644 index 0000000000000..216d1ef1bf4a4 --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/privileges.ts @@ -0,0 +1,49 @@ +/* + * 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 { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { RouteDefinitionParams } from '..'; + +export function defineCheckPrivilegesRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/api_key/privileges', + validate: false, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const scopedClusterClient = clusterClient.asScoped(request); + + const [ + { + cluster: { manage_security: manageSecurity, manage_api_key: manageApiKey }, + }, + { areApiKeysEnabled }, + ] = await Promise.all([ + scopedClusterClient.callAsCurrentUser('shield.hasPrivileges', { + body: { cluster: ['manage_security', 'manage_api_key'] }, + }), + scopedClusterClient.callAsCurrentUser('shield.getAPIKeys', { owner: true }).then( + // If the API returns a truthy result that means it's enabled. + result => ({ areApiKeysEnabled: !!result }), + // This is a brittle dependency upon message. Tracked by https://github.com/elastic/elasticsearch/issues/47759. + e => + e.message.includes('api keys are not enabled') + ? Promise.resolve({ areApiKeysEnabled: false }) + : Promise.reject(e) + ), + ]); + + return response.ok({ + body: { areApiKeysEnabled, isAdmin: manageSecurity || manageApiKey }, + }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/authentication/basic.test.ts b/x-pack/plugins/security/server/routes/authentication/basic.test.ts new file mode 100644 index 0000000000000..8e24f99b1302d --- /dev/null +++ b/x-pack/plugins/security/server/routes/authentication/basic.test.ts @@ -0,0 +1,172 @@ +/* + * 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 { Type } from '@kbn/config-schema'; +import { + IRouter, + kibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteConfig, +} from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; +import { Authentication, AuthenticationResult } from '../../authentication'; +import { ConfigType } from '../../config'; +import { LegacyAPI } from '../../plugin'; +import { defineBasicRoutes } from './basic'; + +import { + elasticsearchServiceMock, + httpServerMock, + httpServiceMock, + loggingServiceMock, +} from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { authenticationMock } from '../../authentication/index.mock'; +import { authorizationMock } from '../../authorization/index.mock'; + +describe('Basic authentication routes', () => { + let router: jest.Mocked; + let authc: jest.Mocked; + let mockContext: RequestHandlerContext; + beforeEach(() => { + router = httpServiceMock.createRouter(); + authc = authenticationMock.create(); + + mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ check: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + defineBasicRoutes({ + router, + clusterClient: elasticsearchServiceMock.createClusterClient(), + basePath: httpServiceMock.createBasePath(), + logger: loggingServiceMock.create().get(), + config: { authc: { providers: ['saml'] } } as ConfigType, + authc, + authz: authorizationMock.create(), + getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI), + }); + }); + + describe('login', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { username: 'user', password: 'password' }, + }); + + beforeEach(() => { + const [loginRouteConfig, loginRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/login' + )!; + + routeConfig = loginRouteConfig; + routeHandler = loginRouteHandler; + }); + + it('correctly defines route.', async () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.validate).toEqual({ + body: expect.any(Type), + query: undefined, + params: undefined, + }); + + const bodyValidator = (routeConfig.validate as any).body as Type; + expect(bodyValidator.validate({ username: 'user', password: 'password' })).toEqual({ + username: 'user', + password: 'password', + }); + + expect(() => bodyValidator.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[username]: expected value of type [string] but got [undefined]"` + ); + expect(() => bodyValidator.validate({ username: 'user' })).toThrowErrorMatchingInlineSnapshot( + `"[password]: expected value of type [string] but got [undefined]"` + ); + expect(() => + bodyValidator.validate({ password: 'password' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[username]: expected value of type [string] but got [undefined]"` + ); + expect(() => + bodyValidator.validate({ username: '', password: '' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[username]: value is [] but it must have a minimum length of [1]."` + ); + expect(() => + bodyValidator.validate({ username: 'user', password: '' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[password]: value is [] but it must have a minimum length of [1]."` + ); + expect(() => + bodyValidator.validate({ username: '', password: 'password' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[username]: value is [] but it must have a minimum length of [1]."` + ); + }); + + it('returns 500 if authentication throws unhandled exception.', async () => { + const unhandledException = new Error('Something went wrong.'); + authc.login.mockRejectedValue(unhandledException); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(500); + expect(response.payload).toEqual(unhandledException); + expect(authc.login).toHaveBeenCalledWith(mockRequest, { + provider: 'basic', + value: { username: 'user', password: 'password' }, + }); + }); + + it('returns 401 if authentication fails.', async () => { + const failureReason = new Error('Something went wrong.'); + authc.login.mockResolvedValue(AuthenticationResult.failed(failureReason)); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(401); + expect(response.payload).toEqual(failureReason); + expect(authc.login).toHaveBeenCalledWith(mockRequest, { + provider: 'basic', + value: { username: 'user', password: 'password' }, + }); + }); + + it('returns 401 if authentication is not handled.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.notHandled()); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(401); + expect(response.payload).toEqual('Unauthorized'); + expect(authc.login).toHaveBeenCalledWith(mockRequest, { + provider: 'basic', + value: { username: 'user', password: 'password' }, + }); + }); + + describe('authentication succeeds', () => { + it(`returns user data`, async () => { + authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(204); + expect(response.payload).toBeUndefined(); + expect(authc.login).toHaveBeenCalledWith(mockRequest, { + provider: 'basic', + value: { username: 'user', password: 'password' }, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authentication/basic.ts b/x-pack/plugins/security/server/routes/authentication/basic.ts new file mode 100644 index 0000000000000..453dc1c4ea3b5 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authentication/basic.ts @@ -0,0 +1,48 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { RouteDefinitionParams } from '..'; + +/** + * Defines routes required for Basic/Token authentication. + */ +export function defineBasicRoutes({ router, authc, config }: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/login', + validate: { + body: schema.object({ + username: schema.string({ minLength: 1 }), + password: schema.string({ minLength: 1 }), + }), + }, + options: { authRequired: false }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const { username, password } = request.body; + + try { + // We should prefer `token` over `basic` if possible. + const providerToLoginWith = config.authc.providers.includes('token') ? 'token' : 'basic'; + const authenticationResult = await authc.login(request, { + provider: providerToLoginWith, + value: { username, password }, + }); + + if (!authenticationResult.succeeded()) { + return response.unauthorized({ body: authenticationResult.error }); + } + + return response.noContent(); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/authentication/common.test.ts b/x-pack/plugins/security/server/routes/authentication/common.test.ts new file mode 100644 index 0000000000000..f57fb1d5a7d66 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authentication/common.test.ts @@ -0,0 +1,202 @@ +/* + * 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 { Type } from '@kbn/config-schema'; +import { + IRouter, + kibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteConfig, +} from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; +import { Authentication, DeauthenticationResult } from '../../authentication'; +import { ConfigType } from '../../config'; +import { LegacyAPI } from '../../plugin'; +import { defineCommonRoutes } from './common'; + +import { + elasticsearchServiceMock, + httpServerMock, + httpServiceMock, + loggingServiceMock, +} from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { authenticationMock } from '../../authentication/index.mock'; +import { authorizationMock } from '../../authorization/index.mock'; + +describe('Common authentication routes', () => { + let router: jest.Mocked; + let authc: jest.Mocked; + let mockContext: RequestHandlerContext; + beforeEach(() => { + router = httpServiceMock.createRouter(); + authc = authenticationMock.create(); + + mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ check: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + defineCommonRoutes({ + router, + clusterClient: elasticsearchServiceMock.createClusterClient(), + basePath: httpServiceMock.createBasePath(), + logger: loggingServiceMock.create().get(), + config: { authc: { providers: ['saml'] } } as ConfigType, + authc, + authz: authorizationMock.create(), + getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI), + }); + }); + + describe('logout', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { username: 'user', password: 'password' }, + }); + + beforeEach(() => { + const [loginRouteConfig, loginRouteHandler] = router.get.mock.calls.find( + ([{ path }]) => path === '/api/security/logout' + )!; + + routeConfig = loginRouteConfig; + routeHandler = loginRouteHandler; + }); + + it('correctly defines route.', async () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.validate).toEqual({ + body: undefined, + query: expect.any(Type), + params: undefined, + }); + + const queryValidator = (routeConfig.validate as any).query as Type; + expect(queryValidator.validate({ someRandomField: 'some-random' })).toEqual({ + someRandomField: 'some-random', + }); + expect(queryValidator.validate({})).toEqual({}); + expect(queryValidator.validate(undefined)).toEqual({}); + }); + + it('returns 500 if deauthentication throws unhandled exception.', async () => { + const unhandledException = new Error('Something went wrong.'); + authc.logout.mockRejectedValue(unhandledException); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(500); + expect(response.payload).toEqual(unhandledException); + expect(authc.logout).toHaveBeenCalledWith(mockRequest); + }); + + it('returns 500 if authenticator fails to logout.', async () => { + const failureReason = new Error('Something went wrong.'); + authc.logout.mockResolvedValue(DeauthenticationResult.failed(failureReason)); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(500); + expect(response.payload).toEqual(failureReason); + expect(authc.logout).toHaveBeenCalledWith(mockRequest); + }); + + it('returns 400 for AJAX requests that can not handle redirect.', async () => { + const mockAjaxRequest = httpServerMock.createKibanaRequest({ + headers: { 'kbn-xsrf': 'xsrf' }, + }); + + const response = await routeHandler(mockContext, mockAjaxRequest, kibanaResponseFactory); + + expect(response.status).toBe(400); + expect(response.payload).toEqual('Client should be able to process redirect response.'); + expect(authc.logout).not.toHaveBeenCalled(); + }); + + it('redirects user to the URL returned by authenticator.', async () => { + authc.logout.mockResolvedValue(DeauthenticationResult.redirectTo('https://custom.logout')); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(302); + expect(response.payload).toBeUndefined(); + expect(response.options).toEqual({ headers: { location: 'https://custom.logout' } }); + expect(authc.logout).toHaveBeenCalledWith(mockRequest); + }); + + it('redirects user to the base path if deauthentication succeeds.', async () => { + authc.logout.mockResolvedValue(DeauthenticationResult.succeeded()); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(302); + expect(response.payload).toBeUndefined(); + expect(response.options).toEqual({ headers: { location: '/mock-server-basepath/' } }); + expect(authc.logout).toHaveBeenCalledWith(mockRequest); + }); + + it('redirects user to the base path if deauthentication is not handled.', async () => { + authc.logout.mockResolvedValue(DeauthenticationResult.notHandled()); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(302); + expect(response.payload).toBeUndefined(); + expect(response.options).toEqual({ headers: { location: '/mock-server-basepath/' } }); + expect(authc.logout).toHaveBeenCalledWith(mockRequest); + }); + }); + + describe('me', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { username: 'user', password: 'password' }, + }); + + beforeEach(() => { + const [loginRouteConfig, loginRouteHandler] = router.get.mock.calls.find( + ([{ path }]) => path === '/internal/security/me' + )!; + + routeConfig = loginRouteConfig; + routeHandler = loginRouteHandler; + }); + + it('correctly defines route.', async () => { + expect(routeConfig.options).toBeUndefined(); + expect(routeConfig.validate).toBe(false); + }); + + it('returns 500 if cannot retrieve current user due to unhandled exception.', async () => { + const unhandledException = new Error('Something went wrong.'); + authc.getCurrentUser.mockRejectedValue(unhandledException); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(500); + expect(response.payload).toEqual(unhandledException); + expect(authc.getCurrentUser).toHaveBeenCalledWith(mockRequest); + }); + + it('returns current user.', async () => { + const mockUser = mockAuthenticatedUser(); + authc.getCurrentUser.mockResolvedValue(mockUser); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toEqual(mockUser); + expect(authc.getCurrentUser).toHaveBeenCalledWith(mockRequest); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts new file mode 100644 index 0000000000000..cb4ec196459ee --- /dev/null +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -0,0 +1,78 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { canRedirectRequest } from '../../authentication'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { RouteDefinitionParams } from '..'; + +/** + * Defines routes that are common to various authentication mechanisms. + */ +export function defineCommonRoutes({ router, authc, basePath, logger }: RouteDefinitionParams) { + // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. + for (const path of ['/api/security/logout', '/api/security/v1/logout']) { + router.get( + { + path, + // Allow unknown query parameters as this endpoint can be hit by the 3rd-party with any + // set of query string parameters (e.g. SAML/OIDC logout request parameters). + validate: { query: schema.object({}, { allowUnknowns: true }) }, + options: { authRequired: false }, + }, + async (context, request, response) => { + const serverBasePath = basePath.serverBasePath; + if (path === '/api/security/v1/logout') { + logger.warn( + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/logout" URL instead.`, + { tags: ['deprecation'] } + ); + } + + if (!canRedirectRequest(request)) { + return response.badRequest({ + body: 'Client should be able to process redirect response.', + }); + } + + try { + const deauthenticationResult = await authc.logout(request); + if (deauthenticationResult.failed()) { + return response.customError(wrapIntoCustomErrorResponse(deauthenticationResult.error)); + } + + return response.redirected({ + headers: { location: deauthenticationResult.redirectURL || `${serverBasePath}/` }, + }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + } + ); + } + + // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. + for (const path of ['/internal/security/me', '/api/security/v1/me']) { + router.get( + { path, validate: false }, + createLicensedRouteHandler(async (context, request, response) => { + if (path === '/api/security/v1/me') { + logger.warn( + `The "${basePath.serverBasePath}${path}" endpoint is deprecated and will be removed in the next major version.`, + { tags: ['deprecation'] } + ); + } + + try { + return response.ok({ body: (await authc.getCurrentUser(request)) as any }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); + } +} diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts index 086647dcb3459..21f015cc23b68 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -6,11 +6,39 @@ import { defineSessionRoutes } from './session'; import { defineSAMLRoutes } from './saml'; +import { defineBasicRoutes } from './basic'; +import { defineCommonRoutes } from './common'; +import { defineOIDCRoutes } from './oidc'; import { RouteDefinitionParams } from '..'; +export function createCustomResourceResponse(body: string, contentType: string, cspRules: string) { + return { + body, + headers: { + 'content-type': contentType, + 'cache-control': 'private, no-cache, no-store', + 'content-security-policy': cspRules, + }, + statusCode: 200, + }; +} + export function defineAuthenticationRoutes(params: RouteDefinitionParams) { defineSessionRoutes(params); + defineCommonRoutes(params); + + if ( + params.config.authc.providers.includes('basic') || + params.config.authc.providers.includes('token') + ) { + defineBasicRoutes(params); + } + if (params.config.authc.providers.includes('saml')) { defineSAMLRoutes(params); } + + if (params.config.authc.providers.includes('oidc')) { + defineOIDCRoutes(params); + } } diff --git a/x-pack/plugins/security/server/routes/authentication/oidc.ts b/x-pack/plugins/security/server/routes/authentication/oidc.ts new file mode 100644 index 0000000000000..8483630763ae6 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authentication/oidc.ts @@ -0,0 +1,274 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; +import { KibanaRequest, KibanaResponseFactory } from '../../../../../../src/core/server'; +import { OIDCAuthenticationFlow } from '../../authentication'; +import { createCustomResourceResponse } from '.'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { ProviderLoginAttempt } from '../../authentication/providers/oidc'; +import { RouteDefinitionParams } from '..'; + +/** + * Defines routes required for SAML authentication. + */ +export function defineOIDCRoutes({ + router, + logger, + authc, + getLegacyAPI, + basePath, +}: RouteDefinitionParams) { + // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. + for (const path of ['/api/security/oidc/implicit', '/api/security/v1/oidc/implicit']) { + /** + * The route should be configured as a redirect URI in OP when OpenID Connect implicit flow + * is used, so that we can extract authentication response from URL fragment and send it to + * the `/api/security/oidc` route. + */ + router.get( + { + path, + validate: false, + options: { authRequired: false }, + }, + (context, request, response) => { + const serverBasePath = basePath.serverBasePath; + if (path === '/api/security/v1/oidc/implicit') { + logger.warn( + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc/implicit" URL instead.`, + { tags: ['deprecation'] } + ); + } + return response.custom( + createCustomResourceResponse( + ` + + Kibana OpenID Connect Login + + + `, + 'text/html', + getLegacyAPI().cspRules + ) + ); + } + ); + } + + /** + * The route that accompanies `/api/security/oidc/implicit` and renders a JavaScript snippet + * that extracts fragment part from the URL and send it to the `/api/security/oidc` route. + * We need this separate endpoint because of default CSP policy that forbids inline scripts. + */ + router.get( + { + path: '/internal/security/oidc/implicit.js', + validate: false, + options: { authRequired: false }, + }, + (context, request, response) => { + const serverBasePath = basePath.serverBasePath; + return response.custom( + createCustomResourceResponse( + ` + window.location.replace( + '${serverBasePath}/api/security/oidc?authenticationResponseURI=' + encodeURIComponent(window.location.href) + ); + `, + 'text/javascript', + getLegacyAPI().cspRules + ) + ); + } + ); + + // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. + for (const path of ['/api/security/oidc', '/api/security/v1/oidc']) { + router.get( + { + path, + validate: { + query: schema.object( + { + authenticationResponseURI: schema.maybe(schema.uri()), + code: schema.maybe(schema.string()), + error: schema.maybe(schema.string()), + error_description: schema.maybe(schema.string()), + error_uri: schema.maybe(schema.uri()), + iss: schema.maybe(schema.uri({ scheme: ['https'] })), + login_hint: schema.maybe(schema.string()), + target_link_uri: schema.maybe(schema.uri()), + state: schema.maybe(schema.string()), + }, + // The client MUST ignore unrecognized response parameters according to + // https://openid.net/specs/openid-connect-core-1_0.html#AuthResponseValidation and + // https://tools.ietf.org/html/rfc6749#section-4.1.2. + { allowUnknowns: true } + ), + }, + options: { authRequired: false }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const serverBasePath = basePath.serverBasePath; + + // An HTTP GET request with a query parameter named `authenticationResponseURI` that includes URL fragment OpenID + // Connect Provider sent during implicit authentication flow to the Kibana own proxy page that extracted that URL + // fragment and put it into `authenticationResponseURI` query string parameter for this endpoint. See more details + // at https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth + let loginAttempt: ProviderLoginAttempt | undefined; + if (request.query.authenticationResponseURI) { + loginAttempt = { + flow: OIDCAuthenticationFlow.Implicit, + authenticationResponseURI: request.query.authenticationResponseURI, + }; + } else if (request.query.code || request.query.error) { + if (path === '/api/security/v1/oidc') { + logger.warn( + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc" URL instead.`, + { tags: ['deprecation'] } + ); + } + + // An HTTP GET request with a query parameter named `code` (or `error`) as the response to a successful (or + // failed) authentication from an OpenID Connect Provider during authorization code authentication flow. + // See more details at https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth. + loginAttempt = { + flow: OIDCAuthenticationFlow.AuthorizationCode, + // We pass the path only as we can't be sure of the full URL and Elasticsearch doesn't need it anyway. + authenticationResponseURI: request.url.path!, + }; + } else if (request.query.iss) { + logger.warn( + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc/initiate_login" URL for Third-Party Initiated login instead.`, + { tags: ['deprecation'] } + ); + // An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication. + // See more details at https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin + loginAttempt = { + flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + iss: request.query.iss, + loginHint: request.query.login_hint, + }; + } + + if (!loginAttempt) { + return response.badRequest({ body: 'Unrecognized login attempt.' }); + } + + return performOIDCLogin(request, response, loginAttempt); + }) + ); + } + + // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. + for (const path of ['/api/security/oidc/initiate_login', '/api/security/v1/oidc']) { + /** + * An HTTP POST request with the payload parameter named `iss` as part of a 3rd party initiated authentication. + * See more details at https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin + */ + router.post( + { + path, + validate: { + body: schema.object( + { + iss: schema.uri({ scheme: ['https'] }), + login_hint: schema.maybe(schema.string()), + target_link_uri: schema.maybe(schema.uri()), + }, + // Other parameters MAY be sent, if defined by extensions. Any parameters used that are not understood MUST + // be ignored by the Client according to https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin. + { allowUnknowns: true } + ), + }, + options: { authRequired: false }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const serverBasePath = basePath.serverBasePath; + if (path === '/api/security/v1/oidc') { + logger.warn( + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc/initiate_login" URL for Third-Party Initiated login instead.`, + { tags: ['deprecation'] } + ); + } + + return performOIDCLogin(request, response, { + flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + iss: request.body.iss, + loginHint: request.body.login_hint, + }); + }) + ); + } + + /** + * An HTTP GET request with the query string parameter named `iss` as part of a 3rd party initiated authentication. + * See more details at https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin + */ + router.get( + { + path: '/api/security/oidc/initiate_login', + validate: { + query: schema.object( + { + iss: schema.uri({ scheme: ['https'] }), + login_hint: schema.maybe(schema.string()), + target_link_uri: schema.maybe(schema.uri()), + }, + // Other parameters MAY be sent, if defined by extensions. Any parameters used that are not understood MUST + // be ignored by the Client according to https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin. + { allowUnknowns: true } + ), + }, + options: { authRequired: false }, + }, + createLicensedRouteHandler(async (context, request, response) => { + return performOIDCLogin(request, response, { + flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + iss: request.query.iss, + loginHint: request.query.login_hint, + }); + }) + ); + + async function performOIDCLogin( + request: KibanaRequest, + response: KibanaResponseFactory, + loginAttempt: ProviderLoginAttempt + ) { + try { + // We handle the fact that the user might get redirected to Kibana while already having a session + // Return an error notifying the user they are already logged in. + const authenticationResult = await authc.login(request, { + provider: 'oidc', + value: loginAttempt, + }); + + if (authenticationResult.succeeded()) { + return response.forbidden({ + body: i18n.translate('xpack.security.conflictingSessionError', { + defaultMessage: + 'Sorry, you already have an active Kibana session. ' + + 'If you want to start a new one, please logout from the existing session first.', + }), + }); + } + + if (authenticationResult.redirected()) { + return response.redirected({ + headers: { location: authenticationResult.redirectURL! }, + }); + } + + return response.unauthorized({ body: authenticationResult.error }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + } +} diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index 61f40e583d24e..f724d0e7708be 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -6,6 +6,7 @@ import { schema } from '@kbn/config-schema'; import { SAMLLoginStep } from '../../authentication'; +import { createCustomResourceResponse } from '.'; import { RouteDefinitionParams } from '..'; /** @@ -18,18 +19,6 @@ export function defineSAMLRoutes({ getLegacyAPI, basePath, }: RouteDefinitionParams) { - function createCustomResourceResponse(body: string, contentType: string) { - return { - body, - headers: { - 'content-type': contentType, - 'cache-control': 'private, no-cache, no-store', - 'content-security-policy': getLegacyAPI().cspRules, - }, - statusCode: 200, - }; - } - router.get( { path: '/api/security/saml/capture-url-fragment', @@ -46,7 +35,8 @@ export function defineSAMLRoutes({ `, - 'text/html' + 'text/html', + getLegacyAPI().cspRules ) ); } @@ -66,7 +56,8 @@ export function defineSAMLRoutes({ '${basePath.serverBasePath}/api/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) ); `, - 'text/javascript' + 'text/javascript', + getLegacyAPI().cspRules ) ); } diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts index 10fe0cdd67811..6afbad8e83ebe 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts @@ -81,7 +81,7 @@ describe('GET privileges', () => { }; describe('failure', () => { - getPrivilegesTest(`returns result of routePreCheckLicense`, { + getPrivilegesTest('returns result of license checker', { licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts index 61c5747550d75..22268245c3a44 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts @@ -73,16 +73,18 @@ describe('DELETE role', () => { }; describe('failure', () => { - deleteRoleTest(`returns result of license checker`, { + deleteRoleTest('returns result of license checker', { name: 'foo-role', licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); const error = Boom.notFound('test not found message'); - deleteRoleTest(`returns error from cluster client`, { + deleteRoleTest('returns error from cluster client', { name: 'foo-role', - apiResponse: () => Promise.reject(error), + apiResponse: async () => { + throw error; + }, asserts: { statusCode: 404, result: error }, }); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/delete.ts b/x-pack/plugins/security/server/routes/authorization/roles/delete.ts index aab815fbe449f..de966d6f2a758 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/delete.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/delete.ts @@ -7,7 +7,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDefinitionParams } from '../../index'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; -import { wrapError } from '../../../errors'; +import { wrapIntoCustomErrorResponse } from '../../../errors'; export function defineDeleteRolesRoutes({ router, clusterClient }: RouteDefinitionParams) { router.delete( @@ -23,11 +23,7 @@ export function defineDeleteRolesRoutes({ router, clusterClient }: RouteDefiniti return response.noContent(); } catch (error) { - const wrappedError = wrapError(error); - return response.customError({ - body: wrappedError, - statusCode: wrappedError.output.statusCode, - }); + return response.customError(wrapIntoCustomErrorResponse(error)); } }) ); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts index 1cfc1ae416ae4..8d78b434ceaa1 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts @@ -75,15 +75,17 @@ describe('GET role', () => { }; describe('failure', () => { - getRoleTest(`returns result of license check`, { + getRoleTest('returns result of license checker', { licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); const error = Boom.notAcceptable('test not acceptable message'); - getRoleTest(`returns error from cluster client`, { + getRoleTest('returns error from cluster client', { name: 'first_role', - apiResponse: () => Promise.reject(error), + apiResponse: async () => { + throw error; + }, asserts: { statusCode: 406, result: error }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.ts index be69e222dd093..fa0e786bb011d 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.ts @@ -7,7 +7,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDefinitionParams } from '../..'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; -import { wrapError } from '../../../errors'; +import { wrapIntoCustomErrorResponse } from '../../../errors'; import { transformElasticsearchRoleToRole } from './model'; export function defineGetRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) { @@ -35,11 +35,7 @@ export function defineGetRolesRoutes({ router, authz, clusterClient }: RouteDefi return response.notFound(); } catch (error) { - const wrappedError = wrapError(error); - return response.customError({ - body: wrappedError, - statusCode: wrappedError.output.statusCode, - }); + return response.customError(wrapIntoCustomErrorResponse(error)); } }) ); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts index 76ce6a272e285..6415cfb56d86e 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts @@ -67,14 +67,16 @@ describe('GET all roles', () => { }; describe('failure', () => { - getRolesTest(`returns result of license check`, { + getRolesTest('returns result of license checker', { licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); const error = Boom.notAcceptable('test not acceptable message'); - getRolesTest(`returns error from cluster client`, { - apiResponse: () => Promise.reject(error), + getRolesTest('returns error from cluster client', { + apiResponse: async () => { + throw error; + }, asserts: { statusCode: 406, result: error }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts index f5d2d51280fc4..bfbc8eb01cb23 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts @@ -6,7 +6,7 @@ import { RouteDefinitionParams } from '../..'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; -import { wrapError } from '../../../errors'; +import { wrapIntoCustomErrorResponse } from '../../../errors'; import { ElasticsearchRole, transformElasticsearchRoleToRole } from './model'; export function defineGetAllRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) { @@ -41,11 +41,7 @@ export function defineGetAllRolesRoutes({ router, authz, clusterClient }: RouteD }), }); } catch (error) { - const wrappedError = wrapError(error); - return response.customError({ - body: wrappedError, - statusCode: wrappedError.output.statusCode, - }); + return response.customError(wrapIntoCustomErrorResponse(error)); } }) ); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts index 31963987c2efb..611334737a315 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -138,7 +138,7 @@ describe('PUT role', () => { }); describe('failure', () => { - putRoleTest(`returns result of license checker`, { + putRoleTest('returns result of license checker', { name: 'foo-role', licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.ts index 92c940132e660..f18399d23297b 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.ts @@ -7,7 +7,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDefinitionParams } from '../../index'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; -import { wrapError } from '../../../errors'; +import { wrapIntoCustomErrorResponse } from '../../../errors'; import { ElasticsearchRole, getPutPayloadSchema, @@ -52,11 +52,7 @@ export function definePutRolesRoutes({ router, authz, clusterClient }: RouteDefi return response.noContent(); } catch (error) { - const wrappedError = wrapError(error); - return response.customError({ - body: wrappedError, - statusCode: wrappedError.output.statusCode, - }); + return response.customError(wrapIntoCustomErrorResponse(error)); } }) ); diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 73e276832f474..756eaa76e2c2e 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -12,6 +12,9 @@ import { LegacyAPI } from '../plugin'; import { defineAuthenticationRoutes } from './authentication'; import { defineAuthorizationRoutes } from './authorization'; +import { defineApiKeysRoutes } from './api_keys'; +import { defineIndicesRoutes } from './indices'; +import { defineUsersRoutes } from './users'; /** * Describes parameters used to define HTTP routes. @@ -30,4 +33,7 @@ export interface RouteDefinitionParams { export function defineRoutes(params: RouteDefinitionParams) { defineAuthenticationRoutes(params); defineAuthorizationRoutes(params); + defineApiKeysRoutes(params); + defineIndicesRoutes(params); + defineUsersRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/indices/get_fields.ts b/x-pack/plugins/security/server/routes/indices/get_fields.ts new file mode 100644 index 0000000000000..64c3d4f7471ef --- /dev/null +++ b/x-pack/plugins/security/server/routes/indices/get_fields.ts @@ -0,0 +1,47 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '../index'; +import { wrapIntoCustomErrorResponse } from '../../errors'; + +export function defineGetFieldsRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/fields/{query}', + validate: { params: schema.object({ query: schema.string() }) }, + }, + async (context, request, response) => { + try { + const indexMappings = (await clusterClient + .asScoped(request) + .callAsCurrentUser('indices.getFieldMapping', { + index: request.params.query, + fields: '*', + allowNoIndices: false, + includeDefaults: true, + })) as Record }>; + + // The flow is the following (see response format at https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-field-mapping.html): + // 1. Iterate over all matched indices. + // 2. Extract all the field names from the `mappings` field of the particular index. + // 3. Collect and flatten the list of the field names. + // 4. Use `Set` to get only unique field names. + return response.ok({ + body: Array.from( + new Set( + Object.values(indexMappings) + .map(indexMapping => Object.keys(indexMapping.mappings)) + .flat() + ) + ), + }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + } + ); +} diff --git a/x-pack/legacy/plugins/security/common/constants.ts b/x-pack/plugins/security/server/routes/indices/index.ts similarity index 54% rename from x-pack/legacy/plugins/security/common/constants.ts rename to x-pack/plugins/security/server/routes/indices/index.ts index 08e49ad995550..d6b5eccf0fada 100644 --- a/x-pack/legacy/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/server/routes/indices/index.ts @@ -4,4 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export const INTERNAL_API_BASE_PATH = '/internal/security'; +import { defineGetFieldsRoutes } from './get_fields'; +import { RouteDefinitionParams } from '..'; + +export function defineIndicesRoutes(params: RouteDefinitionParams) { + defineGetFieldsRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts new file mode 100644 index 0000000000000..9f88d28bc115f --- /dev/null +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -0,0 +1,207 @@ +/* + * 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 { ObjectType } from '@kbn/config-schema'; +import { + IClusterClient, + IRouter, + IScopedClusterClient, + kibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteConfig, +} from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; +import { Authentication, AuthenticationResult } from '../../authentication'; +import { ConfigType } from '../../config'; +import { LegacyAPI } from '../../plugin'; +import { defineChangeUserPasswordRoutes } from './change_password'; + +import { + elasticsearchServiceMock, + loggingServiceMock, + httpServiceMock, + httpServerMock, +} from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { authorizationMock } from '../../authorization/index.mock'; +import { authenticationMock } from '../../authentication/index.mock'; + +describe('Change password', () => { + let router: jest.Mocked; + let authc: jest.Mocked; + let mockClusterClient: jest.Mocked; + let mockScopedClusterClient: jest.Mocked; + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + let mockContext: RequestHandlerContext; + + function checkPasswordChangeAPICall( + username: string, + request: ReturnType + ) { + expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.changePassword', + { username, body: { password: 'new-password' } } + ); + } + + beforeEach(() => { + router = httpServiceMock.createRouter(); + authc = authenticationMock.create(); + + authc.getCurrentUser.mockResolvedValue(mockAuthenticatedUser({ username: 'user' })); + authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); + + mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + + mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ check: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + defineChangeUserPasswordRoutes({ + router, + clusterClient: mockClusterClient, + basePath: httpServiceMock.createBasePath(), + logger: loggingServiceMock.create().get(), + config: { authc: { providers: ['saml'] } } as ConfigType, + authc, + authz: authorizationMock.create(), + getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI), + }); + + const [changePasswordRouteConfig, changePasswordRouteHandler] = router.post.mock.calls[0]; + routeConfig = changePasswordRouteConfig; + routeHandler = changePasswordRouteHandler; + }); + + it('correctly defines route.', async () => { + expect(routeConfig.path).toBe('/internal/security/users/{username}/password'); + + const paramsSchema = (routeConfig.validate as any).params as ObjectType; + expect(() => paramsSchema.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[username]: expected value of type [string] but got [undefined]"` + ); + expect(() => paramsSchema.validate({ username: '' })).toThrowErrorMatchingInlineSnapshot( + `"[username]: value is [] but it must have a minimum length of [1]."` + ); + expect(() => + paramsSchema.validate({ username: 'a'.repeat(1025) }) + ).toThrowErrorMatchingInlineSnapshot( + `"[username]: value is [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa] but it must have a maximum length of [1024]."` + ); + + const bodySchema = (routeConfig.validate as any).body as ObjectType; + expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[newPassword]: expected value of type [string] but got [undefined]"` + ); + expect(() => bodySchema.validate({ newPassword: '' })).toThrowErrorMatchingInlineSnapshot( + `"[newPassword]: value is [] but it must have a minimum length of [1]."` + ); + expect(() => + bodySchema.validate({ newPassword: '123456', password: '' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[password]: value is [] but it must have a minimum length of [1]."` + ); + }); + + describe('own password', () => { + const username = 'user'; + const mockRequest = httpServerMock.createKibanaRequest({ + params: { username }, + body: { password: 'old-password', newPassword: 'new-password' }, + }); + + it('returns 403 if old password is wrong.', async () => { + const loginFailureReason = new Error('Something went wrong.'); + authc.login.mockResolvedValue(AuthenticationResult.failed(loginFailureReason)); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(403); + expect(response.payload).toEqual(loginFailureReason); + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); + }); + + it(`returns 401 if user can't authenticate with new password.`, async () => { + const loginFailureReason = new Error('Something went wrong.'); + authc.login.mockImplementation(async (request, attempt) => { + const credentials = attempt.value as { username: string; password: string }; + if (credentials.username === 'user' && credentials.password === 'new-password') { + return AuthenticationResult.failed(loginFailureReason); + } + + return AuthenticationResult.succeeded(mockAuthenticatedUser()); + }); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(401); + expect(response.payload).toEqual(loginFailureReason); + + checkPasswordChangeAPICall(username, mockRequest); + }); + + it('returns 500 if password update request fails.', async () => { + const failureReason = new Error('Request failed.'); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(500); + expect(response.payload).toEqual(failureReason); + + checkPasswordChangeAPICall(username, mockRequest); + }); + + it('successfully changes own password if provided old password is correct.', async () => { + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(204); + expect(response.payload).toBeUndefined(); + + checkPasswordChangeAPICall(username, mockRequest); + }); + }); + + describe('other user password', () => { + const username = 'target-user'; + const mockRequest = httpServerMock.createKibanaRequest({ + params: { username }, + body: { newPassword: 'new-password' }, + }); + + it('returns 500 if password update request fails.', async () => { + const failureReason = new Error('Request failed.'); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(500); + expect(response.payload).toEqual(failureReason); + expect(authc.login).not.toHaveBeenCalled(); + + checkPasswordChangeAPICall(username, mockRequest); + }); + + it('successfully changes user password.', async () => { + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(204); + expect(response.payload).toBeUndefined(); + expect(authc.login).not.toHaveBeenCalled(); + + checkPasswordChangeAPICall(username, mockRequest); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts new file mode 100644 index 0000000000000..b9d04b4bd1e0e --- /dev/null +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -0,0 +1,80 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { RouteDefinitionParams } from '..'; + +export function defineChangeUserPasswordRoutes({ + authc, + router, + clusterClient, + config, +}: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/users/{username}/password', + validate: { + params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }), + body: schema.object({ + password: schema.maybe(schema.string({ minLength: 1 })), + newPassword: schema.string({ minLength: 1 }), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const username = request.params.username; + const { password, newPassword } = request.body; + const isCurrentUser = username === (await authc.getCurrentUser(request))!.username; + + // We should prefer `token` over `basic` if possible. + const providerToLoginWith = config.authc.providers.includes('token') ? 'token' : 'basic'; + + // If user tries to change own password, let's check if old password is valid first by trying + // to login. + if (isCurrentUser) { + try { + const authenticationResult = await authc.login(request, { + provider: providerToLoginWith, + value: { username, password }, + // We shouldn't alter authentication state just yet. + stateless: true, + }); + + if (!authenticationResult.succeeded()) { + return response.forbidden({ body: authenticationResult.error }); + } + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + } + + try { + await clusterClient.asScoped(request).callAsCurrentUser('shield.changePassword', { + username, + body: { password: newPassword }, + }); + + // Now we authenticate user with the new password again updating current session if any. + if (isCurrentUser) { + const authenticationResult = await authc.login(request, { + provider: providerToLoginWith, + value: { username, password: newPassword }, + }); + + if (!authenticationResult.succeeded()) { + return response.unauthorized({ body: authenticationResult.error }); + } + } + + return response.noContent(); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/users/create_or_update.ts b/x-pack/plugins/security/server/routes/users/create_or_update.ts new file mode 100644 index 0000000000000..5a3e50bb11d5c --- /dev/null +++ b/x-pack/plugins/security/server/routes/users/create_or_update.ts @@ -0,0 +1,47 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { RouteDefinitionParams } from '..'; + +export function defineCreateOrUpdateUserRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/users/{username}', + validate: { + params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }), + body: schema.object({ + username: schema.string({ minLength: 1, maxLength: 1024 }), + password: schema.maybe(schema.string({ minLength: 1 })), + roles: schema.arrayOf(schema.string({ minLength: 1 })), + full_name: schema.maybe(schema.oneOf([schema.literal(null), schema.string()])), + email: schema.maybe(schema.oneOf([schema.literal(null), schema.string()])), + metadata: schema.maybe(schema.recordOf(schema.string(), schema.any())), + enabled: schema.boolean({ defaultValue: true }), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + await clusterClient.asScoped(request).callAsCurrentUser('shield.putUser', { + username: request.params.username, + // Omit `username`, `enabled` and all fields with `null` value. + body: Object.fromEntries( + Object.entries(request.body).filter( + ([key, value]) => value !== null && key !== 'enabled' && key !== 'username' + ) + ), + }); + + return response.ok({ body: request.body }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/users/delete.ts b/x-pack/plugins/security/server/routes/users/delete.ts new file mode 100644 index 0000000000000..99a8d5c18ab3d --- /dev/null +++ b/x-pack/plugins/security/server/routes/users/delete.ts @@ -0,0 +1,32 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '../index'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +export function defineDeleteUserRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.delete( + { + path: '/internal/security/users/{username}', + validate: { + params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.deleteUser', { username: request.params.username }); + + return response.noContent(); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/users/get.ts b/x-pack/plugins/security/server/routes/users/get.ts new file mode 100644 index 0000000000000..0867910372546 --- /dev/null +++ b/x-pack/plugins/security/server/routes/users/get.ts @@ -0,0 +1,37 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { RouteDefinitionParams } from '..'; + +export function defineGetUserRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/users/{username}', + validate: { + params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const username = request.params.username; + const users = (await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getUser', { username })) as Record; + + if (!users[username]) { + return response.notFound(); + } + + return response.ok({ body: users[username] }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/users/get_all.ts b/x-pack/plugins/security/server/routes/users/get_all.ts new file mode 100644 index 0000000000000..492ab27ab27ad --- /dev/null +++ b/x-pack/plugins/security/server/routes/users/get_all.ts @@ -0,0 +1,27 @@ +/* + * 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 { RouteDefinitionParams } from '../index'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +export function defineGetAllUsersRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.get( + { path: '/internal/security/users', validate: false }, + createLicensedRouteHandler(async (context, request, response) => { + try { + return response.ok({ + // Return only values since keys (user names) are already duplicated there. + body: Object.values( + await clusterClient.asScoped(request).callAsCurrentUser('shield.getUser') + ), + }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/users/index.ts b/x-pack/plugins/security/server/routes/users/index.ts new file mode 100644 index 0000000000000..931af0734b416 --- /dev/null +++ b/x-pack/plugins/security/server/routes/users/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { RouteDefinitionParams } from '../index'; +import { defineGetUserRoutes } from './get'; +import { defineGetAllUsersRoutes } from './get_all'; +import { defineCreateOrUpdateUserRoutes } from './create_or_update'; +import { defineDeleteUserRoutes } from './delete'; +import { defineChangeUserPasswordRoutes } from './change_password'; + +export function defineUsersRoutes(params: RouteDefinitionParams) { + defineGetUserRoutes(params); + defineGetAllUsersRoutes(params); + defineCreateOrUpdateUserRoutes(params); + defineDeleteUserRoutes(params); + defineChangeUserPasswordRoutes(params); +} diff --git a/x-pack/test/api_integration/apis/security/basic_login.js b/x-pack/test/api_integration/apis/security/basic_login.js index 1d10b3f8803a5..cd85e6906d65e 100644 --- a/x-pack/test/api_integration/apis/security/basic_login.js +++ b/x-pack/test/api_integration/apis/security/basic_login.js @@ -32,7 +32,7 @@ export default function ({ getService }) { it('should reject API requests if client is not authenticated', async () => { await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .expect(401); }); @@ -41,24 +41,24 @@ export default function ({ getService }) { const wrongUsername = `wrong-${validUsername}`; const wrongPassword = `wrong-${validPassword}`; - await supertest.post('/api/security/v1/login') + await supertest.post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: wrongUsername, password: wrongPassword }) .expect(401); - await supertest.post('/api/security/v1/login') + await supertest.post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: validUsername, password: wrongPassword }) .expect(401); - await supertest.post('/api/security/v1/login') + await supertest.post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: wrongUsername, password: validPassword }) .expect(401); }); it('should set authentication cookie for login with valid credentials', async () => { - const loginResponse = await supertest.post('/api/security/v1/login') + const loginResponse = await supertest.post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: validUsername, password: validPassword }) .expect(204); @@ -77,17 +77,17 @@ export default function ({ getService }) { const wrongUsername = `wrong-${validUsername}`; const wrongPassword = `wrong-${validPassword}`; - await supertest.get('/api/security/v1/me') + await supertest.get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Authorization', `Basic ${Buffer.from(`${wrongUsername}:${wrongPassword}`).toString('base64')}`) .expect(401); - await supertest.get('/api/security/v1/me') + await supertest.get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Authorization', `Basic ${Buffer.from(`${validUsername}:${wrongPassword}`).toString('base64')}`) .expect(401); - await supertest.get('/api/security/v1/me') + await supertest.get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Authorization', `Basic ${Buffer.from(`${wrongUsername}:${validPassword}`).toString('base64')}`) .expect(401); @@ -95,7 +95,7 @@ export default function ({ getService }) { it('should allow access to the API with valid credentials in the header', async () => { const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Authorization', `Basic ${Buffer.from(`${validUsername}:${validPassword}`).toString('base64')}`) .expect(200); @@ -116,7 +116,7 @@ export default function ({ getService }) { describe('with session cookie', () => { let sessionCookie; beforeEach(async () => { - const loginResponse = await supertest.post('/api/security/v1/login') + const loginResponse = await supertest.post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: validUsername, password: validPassword }) .expect(204); @@ -128,12 +128,12 @@ export default function ({ getService }) { // There is no session cookie provided and no server side session should have // been established, so request should be rejected. await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .expect(401); const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -153,7 +153,7 @@ export default function ({ getService }) { it('should extend cookie on every successful non-system API call', async () => { const apiResponseOne = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -165,7 +165,7 @@ export default function ({ getService }) { expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); const apiResponseTwo = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -179,7 +179,7 @@ export default function ({ getService }) { it('should not extend cookie for system API calls', async () => { const systemAPIResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('kbn-system-api', 'true') .set('Cookie', sessionCookie.cookieString()) @@ -190,7 +190,7 @@ export default function ({ getService }) { it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Authorization', 'Bearer AbCdEf') .set('Cookie', sessionCookie.cookieString()) @@ -200,7 +200,7 @@ export default function ({ getService }) { }); it('should clear cookie on logout and redirect to login', async ()=> { - const logoutResponse = await supertest.get('/api/security/v1/logout?next=%2Fabc%2Fxyz&msg=test') + const logoutResponse = await supertest.get('/api/security/logout?next=%2Fabc%2Fxyz&msg=test') .set('Cookie', sessionCookie.cookieString()) .expect(302); @@ -256,7 +256,7 @@ export default function ({ getService }) { }); it('should redirect to home page if cookie is not provided', async ()=> { - const logoutResponse = await supertest.get('/api/security/v1/logout') + const logoutResponse = await supertest.get('/api/security/logout') .expect(302); expect(logoutResponse.headers['set-cookie']).to.be(undefined); diff --git a/x-pack/test/api_integration/apis/security/change_password.ts b/x-pack/test/api_integration/apis/security/change_password.ts index 09751d4a3641a..3efb7eb2bb1dd 100644 --- a/x-pack/test/api_integration/apis/security/change_password.ts +++ b/x-pack/test/api_integration/apis/security/change_password.ts @@ -20,7 +20,7 @@ export default function({ getService }: FtrProviderContext) { await security.user.create(mockUserName, { password: mockUserPassword, roles: [] }); const loginResponse = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: mockUserName, password: mockUserPassword }) .expect(204); @@ -34,7 +34,7 @@ export default function({ getService }: FtrProviderContext) { const newPassword = `xxx-${mockUserPassword}-xxx`; await supertest - .post(`/api/security/v1/users/${mockUserName}/password`) + .post(`/internal/security/users/${mockUserName}/password`) .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .send({ password: wrongPassword, newPassword }) @@ -42,21 +42,21 @@ export default function({ getService }: FtrProviderContext) { // Let's check that we can't login with wrong password, just in case. await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: mockUserName, password: wrongPassword }) .expect(401); // Let's check that we can't login with the password we were supposed to set. await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: mockUserName, password: newPassword }) .expect(401); // And can login with the current password. await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: mockUserName, password: mockUserPassword }) .expect(204); @@ -66,7 +66,7 @@ export default function({ getService }: FtrProviderContext) { const newPassword = `xxx-${mockUserPassword}-xxx`; const passwordChangeResponse = await supertest - .post(`/api/security/v1/users/${mockUserName}/password`) + .post(`/internal/security/users/${mockUserName}/password`) .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .send({ password: mockUserPassword, newPassword }) @@ -76,28 +76,28 @@ export default function({ getService }: FtrProviderContext) { // Let's check that previous cookie isn't valid anymore. await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(401); // And that we can't login with the old password. await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: mockUserName, password: mockUserPassword }) .expect(401); // But new cookie should be valid. await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', newSessionCookie.cookieString()) .expect(200); // And that we can login with new credentials. await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: mockUserName, password: newPassword }) .expect(204); diff --git a/x-pack/test/api_integration/apis/security/index_fields.ts b/x-pack/test/api_integration/apis/security/index_fields.ts index 60c6e800c40b2..7adc589fbec3e 100644 --- a/x-pack/test/api_integration/apis/security/index_fields.ts +++ b/x-pack/test/api_integration/apis/security/index_fields.ts @@ -11,10 +11,10 @@ export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('Index Fields', () => { - describe('GET /api/security/v1/fields/{query}', () => { + describe('GET /internal/security/fields/{query}', () => { it('should return a list of available index mapping fields', async () => { await supertest - .get('/api/security/v1/fields/.kibana') + .get('/internal/security/fields/.kibana') .set('kbn-xsrf', 'xxx') .send() .expect(200) diff --git a/x-pack/test/api_integration/apis/security/session.ts b/x-pack/test/api_integration/apis/security/session.ts index 7c7883f58cb30..5d0935bb1ae2d 100644 --- a/x-pack/test/api_integration/apis/security/session.ts +++ b/x-pack/test/api_integration/apis/security/session.ts @@ -43,7 +43,7 @@ export default function({ getService }: FtrProviderContext) { beforeEach(async () => { await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: validUsername, password: validPassword }) .expect(204) diff --git a/x-pack/test/api_integration/services/legacy_es.js b/x-pack/test/api_integration/services/legacy_es.js index 1518550407529..12a1576f78982 100644 --- a/x-pack/test/api_integration/services/legacy_es.js +++ b/x-pack/test/api_integration/services/legacy_es.js @@ -8,7 +8,7 @@ import { format as formatUrl } from 'url'; import * as legacyElasticsearch from 'elasticsearch'; -import shieldPlugin from '../../../legacy/server/lib/esjs_shield_plugin'; +import { elasticsearchClientPlugin } from '../../../plugins/security/server/elasticsearch_client_plugin'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { DEFAULT_API_VERSION } from '../../../../src/core/server/elasticsearch/elasticsearch_config'; @@ -19,6 +19,6 @@ export function LegacyEsProvider({ getService }) { apiVersion: DEFAULT_API_VERSION, host: formatUrl(config.get('servers.elasticsearch')), requestTimeout: config.get('timeouts.esRequestTimeout'), - plugins: [shieldPlugin], + plugins: [elasticsearchClientPlugin], }); } diff --git a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts index 450f7b1a427dc..0346da334d2f2 100644 --- a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts +++ b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts @@ -47,7 +47,7 @@ export default function({ getService }: FtrProviderContext) { it('should reject API requests if client is not authenticated', async () => { await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .expect(401); }); @@ -55,7 +55,7 @@ export default function({ getService }: FtrProviderContext) { it('does not prevent basic login', async () => { const [username, password] = config.get('servers.elasticsearch.auth').split(':'); const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username, password }) .expect(204); @@ -67,7 +67,7 @@ export default function({ getService }: FtrProviderContext) { checkCookieIsSet(cookie); const { body: user } = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', cookie.cookieString()) .expect(200); @@ -98,7 +98,7 @@ export default function({ getService }: FtrProviderContext) { describe('finishing SPNEGO', () => { it('should properly set cookie and authenticate user', async () => { const response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('Authorization', `Negotiate ${spnegoToken}`) .expect(200); @@ -114,7 +114,7 @@ export default function({ getService }: FtrProviderContext) { checkCookieIsSet(sessionCookie); await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200, { @@ -134,7 +134,7 @@ export default function({ getService }: FtrProviderContext) { it('should re-initiate SPNEGO handshake if token is rejected with 401', async () => { const spnegoResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('Authorization', `Negotiate ${Buffer.from('Hello').toString('base64')}`) .expect(401); expect(spnegoResponse.headers['set-cookie']).to.be(undefined); @@ -143,7 +143,7 @@ export default function({ getService }: FtrProviderContext) { it('should fail if SPNEGO token is rejected because of unknown reason', async () => { const spnegoResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('Authorization', 'Negotiate (:I am malformed:)') .expect(500); expect(spnegoResponse.headers['set-cookie']).to.be(undefined); @@ -156,7 +156,7 @@ export default function({ getService }: FtrProviderContext) { beforeEach(async () => { const response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('Authorization', `Negotiate ${spnegoToken}`) .expect(200); @@ -169,7 +169,7 @@ export default function({ getService }: FtrProviderContext) { it('should extend cookie on every successful non-system API call', async () => { const apiResponseOne = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -181,7 +181,7 @@ export default function({ getService }: FtrProviderContext) { expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); const apiResponseTwo = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -195,7 +195,7 @@ export default function({ getService }: FtrProviderContext) { it('should not extend cookie for system API calls', async () => { const systemAPIResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('kbn-system-api', 'true') .set('Cookie', sessionCookie.cookieString()) @@ -206,7 +206,7 @@ export default function({ getService }: FtrProviderContext) { it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Authorization', 'Basic a3JiNTprcmI1') .set('Cookie', sessionCookie.cookieString()) @@ -220,7 +220,7 @@ export default function({ getService }: FtrProviderContext) { it('should redirect to `logged_out` page after successful logout', async () => { // First authenticate user to retrieve session cookie. const response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('Authorization', `Negotiate ${spnegoToken}`) .expect(200); @@ -232,7 +232,7 @@ export default function({ getService }: FtrProviderContext) { // And then log user out. const logoutResponse = await supertest - .get('/api/security/v1/logout') + .get('/api/security/logout') .set('Cookie', sessionCookie.cookieString()) .expect(302); @@ -245,7 +245,7 @@ export default function({ getService }: FtrProviderContext) { // Token that was stored in the previous cookie should be invalidated as well and old // session cookie should not allow API access. const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(401); @@ -259,7 +259,7 @@ export default function({ getService }: FtrProviderContext) { }); it('should redirect to home page if session cookie is not provided', async () => { - const logoutResponse = await supertest.get('/api/security/v1/logout').expect(302); + const logoutResponse = await supertest.get('/api/security/logout').expect(302); expect(logoutResponse.headers['set-cookie']).to.be(undefined); expect(logoutResponse.headers.location).to.be('/'); @@ -271,7 +271,7 @@ export default function({ getService }: FtrProviderContext) { beforeEach(async () => { const response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('Authorization', `Negotiate ${spnegoToken}`) .expect(200); @@ -292,7 +292,7 @@ export default function({ getService }: FtrProviderContext) { // This api call should succeed and automatically refresh token. Returned cookie will contain // the new access and refresh token pair. const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -305,7 +305,7 @@ export default function({ getService }: FtrProviderContext) { // The first new cookie with fresh pair of access and refresh tokens should work. await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', refreshedCookie.cookieString()) .expect(200); @@ -335,7 +335,7 @@ export default function({ getService }: FtrProviderContext) { // The first new cookie with fresh pair of access and refresh tokens should work. await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', refreshedCookie.cookieString()) .expect(200); @@ -349,7 +349,7 @@ export default function({ getService }: FtrProviderContext) { beforeEach(async () => { const response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('Authorization', `Negotiate ${spnegoToken}`) .expect(200); @@ -374,7 +374,7 @@ export default function({ getService }: FtrProviderContext) { it('AJAX call should initiate SPNEGO and clear existing cookie', async function() { const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(401); diff --git a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js index 80ef6bd6df4ff..95958d12a42d7 100644 --- a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js +++ b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js @@ -16,7 +16,7 @@ export default function ({ getService }) { describe('OpenID Connect authentication', () => { it('should reject API requests if client is not authenticated', async () => { await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .expect(401); }); @@ -46,7 +46,8 @@ export default function ({ getService }) { }); it('should properly set cookie, return all parameters and redirect user for Third Party initiated', async () => { - const handshakeResponse = await supertest.get('/api/security/v1/oidc?iss=https://test-op.elastic.co') + const handshakeResponse = await supertest.post('/api/security/oidc/initiate_login') + .send({ iss: 'https://test-op.elastic.co' }) .expect(302); const cookies = handshakeResponse.headers['set-cookie']; @@ -74,7 +75,7 @@ export default function ({ getService }) { const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', handshakeCookie.cookieString()) .expect(401); @@ -108,20 +109,20 @@ export default function ({ getService }) { }); it('should fail if OpenID Connect response is not complemented with handshake cookie', async () => { - await supertest.get(`/api/security/v1/oidc?code=thisisthecode&state=${stateAndNonce.state}`) + await supertest.get(`/api/security/oidc?code=thisisthecode&state=${stateAndNonce.state}`) .set('kbn-xsrf', 'xxx') .expect(401); }); it('should fail if state is not matching', async () => { - await supertest.get(`/api/security/v1/oidc?code=thisisthecode&state=someothervalue`) + await supertest.get(`/api/security/oidc?code=thisisthecode&state=someothervalue`) .set('kbn-xsrf', 'xxx') .set('Cookie', handshakeCookie.cookieString()) .expect(401); }); it('should succeed if both the OpenID Connect response and the cookie are provided', async () => { - const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`) + const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code1&state=${stateAndNonce.state}`) .set('kbn-xsrf', 'xxx') .set('Cookie', handshakeCookie.cookieString()) .expect(302); @@ -139,7 +140,7 @@ export default function ({ getService }) { expect(sessionCookie.httpOnly).to.be(true); const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -160,7 +161,7 @@ export default function ({ getService }) { describe('Complete third party initiated authentication', () => { it('should authenticate a user when a third party initiates the authentication', async () => { - const handshakeResponse = await supertest.get('/api/security/v1/oidc?iss=https://test-op.elastic.co') + const handshakeResponse = await supertest.get('/api/security/oidc/initiate_login?iss=https://test-op.elastic.co') .expect(302); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); @@ -172,7 +173,7 @@ export default function ({ getService }) { .send({ nonce: stateAndNonce.nonce }) .expect(200); - const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code2&state=${stateAndNonce.state}`) + const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code2&state=${stateAndNonce.state}`) .set('kbn-xsrf', 'xxx') .set('Cookie', handshakeCookie.cookieString()) .expect(302); @@ -186,7 +187,7 @@ export default function ({ getService }) { expect(sessionCookie.httpOnly).to.be(true); const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -222,7 +223,7 @@ export default function ({ getService }) { .send({ nonce: stateAndNonce.nonce }) .expect(200); - const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`) + const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code1&state=${stateAndNonce.state}`) .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(302); @@ -232,7 +233,7 @@ export default function ({ getService }) { it('should extend cookie on every successful non-system API call', async () => { const apiResponseOne = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -244,7 +245,7 @@ export default function ({ getService }) { expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); const apiResponseTwo = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -258,7 +259,7 @@ export default function ({ getService }) { it('should not extend cookie for system API calls', async () => { const systemAPIResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('kbn-system-api', 'true') .set('Cookie', sessionCookie.cookieString()) @@ -269,7 +270,7 @@ export default function ({ getService }) { it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Authorization', 'Basic AbCdEf') .set('Cookie', sessionCookie.cookieString()) @@ -295,7 +296,7 @@ export default function ({ getService }) { .send({ nonce: stateAndNonce.nonce }) .expect(200); - const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`) + const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code1&state=${stateAndNonce.state}`) .set('kbn-xsrf', 'xxx') .set('Cookie', handshakeCookie.cookieString()) .expect(302); @@ -307,7 +308,7 @@ export default function ({ getService }) { }); it('should redirect to home page if session cookie is not provided', async () => { - const logoutResponse = await supertest.get('/api/security/v1/logout') + const logoutResponse = await supertest.get('/api/security/logout') .expect(302); expect(logoutResponse.headers['set-cookie']).to.be(undefined); @@ -315,7 +316,7 @@ export default function ({ getService }) { }); it('should redirect to the OPs endsession endpoint to complete logout', async () => { - const logoutResponse = await supertest.get('/api/security/v1/logout') + const logoutResponse = await supertest.get('/api/security/logout') .set('Cookie', sessionCookie.cookieString()) .expect(302); @@ -336,7 +337,7 @@ export default function ({ getService }) { // Tokens that were stored in the previous cookie should be invalidated as well and old // session cookie should not allow API access. const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(400); @@ -349,7 +350,7 @@ export default function ({ getService }) { }); it('should reject AJAX requests', async () => { - const ajaxResponse = await supertest.get('/api/security/v1/logout') + const ajaxResponse = await supertest.get('/api/security/logout') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(400); @@ -379,7 +380,7 @@ export default function ({ getService }) { .send({ nonce: stateAndNonce.nonce }) .expect(200); - const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`) + const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code1&state=${stateAndNonce.state}`) .set('kbn-xsrf', 'xxx') .set('Cookie', handshakeCookie.cookieString()) .expect(302); @@ -408,7 +409,7 @@ export default function ({ getService }) { // This api call should succeed and automatically refresh token. Returned cookie will contain // the new access and refresh token pair. const firstResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -422,7 +423,7 @@ export default function ({ getService }) { // Request with old cookie should reuse the same refresh token if within 60 seconds. // Returned cookie will contain the same new access and refresh token pairs as the first request const secondResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -437,14 +438,14 @@ export default function ({ getService }) { // The first new cookie with fresh pair of access and refresh tokens should work. await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', firstNewCookie.cookieString()) .expect(200); // The second new cookie with fresh pair of access and refresh tokens should work. await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', secondNewCookie.cookieString()) .expect(200); @@ -467,7 +468,7 @@ export default function ({ getService }) { .send({ nonce: stateAndNonce.nonce }) .expect(200); - const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`) + const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code1&state=${stateAndNonce.state}`) .set('kbn-xsrf', 'xxx') .set('Cookie', handshakeCookie.cookieString()) .expect(302); diff --git a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts index 23cbb312b092a..0e07f01776713 100644 --- a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts +++ b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts @@ -31,7 +31,7 @@ export default function({ getService }: FtrProviderContext) { }); it('should return an HTML page that will parse URL fragment', async () => { - const response = await supertest.get('/api/security/v1/oidc/implicit').expect(200); + const response = await supertest.get('/api/security/oidc/implicit').expect(200); const dom = new JSDOM(response.text, { url: formatURL({ ...config.get('servers.kibana'), auth: false }), runScripts: 'dangerously', @@ -44,7 +44,7 @@ export default function({ getService }: FtrProviderContext) { Object.defineProperty(window, 'location', { value: { href: - 'https://kibana.com/api/security/v1/oidc/implicit#token=some_token&access_token=some_access_token', + 'https://kibana.com/api/security/oidc/implicit#token=some_token&access_token=some_access_token', replace(newLocation: string) { this.href = newLocation; resolve(); @@ -66,17 +66,17 @@ export default function({ getService }: FtrProviderContext) { // Check that script that forwards URL fragment worked correctly. expect(dom.window.location.href).to.be( - '/api/security/v1/oidc?authenticationResponseURI=https%3A%2F%2Fkibana.com%2Fapi%2Fsecurity%2Fv1%2Foidc%2Fimplicit%23token%3Dsome_token%26access_token%3Dsome_access_token' + '/api/security/oidc?authenticationResponseURI=https%3A%2F%2Fkibana.com%2Fapi%2Fsecurity%2Foidc%2Fimplicit%23token%3Dsome_token%26access_token%3Dsome_access_token' ); }); it('should fail if OpenID Connect response is not complemented with handshake cookie', async () => { const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce); - const authenticationResponse = `https://kibana.com/api/security/v1/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`; + const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`; await supertest .get( - `/api/security/v1/oidc?authenticationResponseURI=${encodeURIComponent( + `/api/security/oidc?authenticationResponseURI=${encodeURIComponent( authenticationResponse )}` ) @@ -86,11 +86,11 @@ export default function({ getService }: FtrProviderContext) { it('should fail if state is not matching', async () => { const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce); - const authenticationResponse = `https://kibana.com/api/security/v1/oidc/implicit#id_token=${idToken}&state=$someothervalue&token_type=bearer&access_token=${accessToken}`; + const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=$someothervalue&token_type=bearer&access_token=${accessToken}`; await supertest .get( - `/api/security/v1/oidc?authenticationResponseURI=${encodeURIComponent( + `/api/security/oidc?authenticationResponseURI=${encodeURIComponent( authenticationResponse )}` ) @@ -102,11 +102,11 @@ export default function({ getService }: FtrProviderContext) { // FLAKY: https://github.com/elastic/kibana/issues/43938 it.skip('should succeed if both the OpenID Connect response and the cookie are provided', async () => { const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce); - const authenticationResponse = `https://kibana.com/api/security/v1/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`; + const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`; const oidcAuthenticationResponse = await supertest .get( - `/api/security/v1/oidc?authenticationResponseURI=${encodeURIComponent( + `/api/security/oidc?authenticationResponseURI=${encodeURIComponent( authenticationResponse )}` ) @@ -129,7 +129,7 @@ export default function({ getService }: FtrProviderContext) { expect(sessionCookie.httpOnly).to.be(true); const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); diff --git a/x-pack/test/oidc_api_integration/config.ts b/x-pack/test/oidc_api_integration/config.ts index f40db4ccbba0a..184ccbcdfa691 100644 --- a/x-pack/test/oidc_api_integration/config.ts +++ b/x-pack/test/oidc_api_integration/config.ts @@ -32,7 +32,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { `xpack.security.authc.realms.oidc.oidc1.rp.client_id=0oa8sqpov3TxMWJOt356`, `xpack.security.authc.realms.oidc.oidc1.rp.client_secret=0oa8sqpov3TxMWJOt356`, `xpack.security.authc.realms.oidc.oidc1.rp.response_type=code`, - `xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=http://localhost:${kibanaPort}/api/security/v1/oidc`, + `xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=http://localhost:${kibanaPort}/api/security/oidc`, `xpack.security.authc.realms.oidc.oidc1.op.authorization_endpoint=https://test-op.elastic.co/oauth2/v1/authorize`, `xpack.security.authc.realms.oidc.oidc1.op.endsession_endpoint=https://test-op.elastic.co/oauth2/v1/endsession`, `xpack.security.authc.realms.oidc.oidc1.op.token_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/token_endpoint`, @@ -52,7 +52,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { '--xpack.security.authc.oidc.realm="oidc1"', '--server.xsrf.whitelist', JSON.stringify([ - '/api/security/v1/oidc', + '/api/security/oidc/initiate_login', '/api/oidc_provider/token_endpoint', '/api/oidc_provider/userinfo_endpoint', ]), diff --git a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts index afb27168d6d5c..4eee900e68bec 100644 --- a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts +++ b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts @@ -57,7 +57,7 @@ export default function({ getService }: FtrProviderContext) { it('should reject API requests that use untrusted certificate', async () => { await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(UNTRUSTED_CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -67,7 +67,7 @@ export default function({ getService }: FtrProviderContext) { it('does not prevent basic login', async () => { const [username, password] = config.get('servers.elasticsearch.auth').split(':'); const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .ca(CA_CERT) .pfx(UNTRUSTED_CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -81,7 +81,7 @@ export default function({ getService }: FtrProviderContext) { checkCookieIsSet(cookie); const { body: user } = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(UNTRUSTED_CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -94,7 +94,7 @@ export default function({ getService }: FtrProviderContext) { it('should properly set cookie and authenticate user', async () => { const response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .expect(200); @@ -122,7 +122,7 @@ export default function({ getService }: FtrProviderContext) { // Cookie should be accepted. await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .set('Cookie', sessionCookie.cookieString()) @@ -131,7 +131,7 @@ export default function({ getService }: FtrProviderContext) { it('should update session if new certificate is provided', async () => { let response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .expect(200); @@ -143,7 +143,7 @@ export default function({ getService }: FtrProviderContext) { checkCookieIsSet(sessionCookie); response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(SECOND_CLIENT_CERT) .set('Cookie', sessionCookie.cookieString()) @@ -167,7 +167,7 @@ export default function({ getService }: FtrProviderContext) { it('should reject valid cookie if used with untrusted certificate', async () => { const response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .expect(200); @@ -179,7 +179,7 @@ export default function({ getService }: FtrProviderContext) { checkCookieIsSet(sessionCookie); await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(UNTRUSTED_CLIENT_CERT) .set('Cookie', sessionCookie.cookieString()) @@ -191,7 +191,7 @@ export default function({ getService }: FtrProviderContext) { beforeEach(async () => { const response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .expect(200); @@ -205,7 +205,7 @@ export default function({ getService }: FtrProviderContext) { it('should extend cookie on every successful non-system API call', async () => { const apiResponseOne = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -219,7 +219,7 @@ export default function({ getService }: FtrProviderContext) { expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); const apiResponseTwo = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -235,7 +235,7 @@ export default function({ getService }: FtrProviderContext) { it('should not extend cookie for system API calls', async () => { const systemAPIResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -248,7 +248,7 @@ export default function({ getService }: FtrProviderContext) { it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -264,7 +264,7 @@ export default function({ getService }: FtrProviderContext) { it('should redirect to `logged_out` page after successful logout', async () => { // First authenticate user to retrieve session cookie. const response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .expect(200); @@ -277,7 +277,7 @@ export default function({ getService }: FtrProviderContext) { // And then log user out. const logoutResponse = await supertest - .get('/api/security/v1/logout') + .get('/api/security/logout') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .set('Cookie', sessionCookie.cookieString()) @@ -292,7 +292,7 @@ export default function({ getService }: FtrProviderContext) { it('should redirect to home page if session cookie is not provided', async () => { const logoutResponse = await supertest - .get('/api/security/v1/logout') + .get('/api/security/logout') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .expect(302); @@ -307,7 +307,7 @@ export default function({ getService }: FtrProviderContext) { beforeEach(async () => { const response = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .expect(200); @@ -329,7 +329,7 @@ export default function({ getService }: FtrProviderContext) { // This api call should succeed and automatically refresh token. Returned cookie will contain // the new access token. const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .ca(CA_CERT) .pfx(FIRST_CLIENT_CERT) .set('kbn-xsrf', 'xxx') diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/saml_api_integration/apis/security/saml_login.ts index 3815788aa746e..0436d59906ea8 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.ts @@ -42,7 +42,7 @@ export default function({ getService }: FtrProviderContext) { expect(sessionCookie.httpOnly).to.be(true); const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -64,7 +64,7 @@ export default function({ getService }: FtrProviderContext) { describe('SAML authentication', () => { it('should reject API requests if client is not authenticated', async () => { await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .expect(401); }); @@ -72,7 +72,7 @@ export default function({ getService }: FtrProviderContext) { it('does not prevent basic login', async () => { const [username, password] = config.get('servers.elasticsearch.auth').split(':'); const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username, password }) .expect(204); @@ -81,7 +81,7 @@ export default function({ getService }: FtrProviderContext) { expect(cookies).to.have.length(1); const { body: user } = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', request.cookie(cookies[0])!.cookieString()) .expect(200); @@ -192,7 +192,7 @@ export default function({ getService }: FtrProviderContext) { const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', handshakeCookie.cookieString()) .expect(401); @@ -300,7 +300,7 @@ export default function({ getService }: FtrProviderContext) { it('should extend cookie on every successful non-system API call', async () => { const apiResponseOne = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -312,7 +312,7 @@ export default function({ getService }: FtrProviderContext) { expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); const apiResponseTwo = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -326,7 +326,7 @@ export default function({ getService }: FtrProviderContext) { it('should not extend cookie for system API calls', async () => { const systemAPIResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('kbn-system-api', 'true') .set('Cookie', sessionCookie.cookieString()) @@ -337,7 +337,7 @@ export default function({ getService }: FtrProviderContext) { it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Authorization', 'Basic AbCdEf') .set('Cookie', sessionCookie.cookieString()) @@ -383,7 +383,7 @@ export default function({ getService }: FtrProviderContext) { it('should redirect to IdP with SAML request to complete logout', async () => { const logoutResponse = await supertest - .get('/api/security/v1/logout') + .get('/api/security/logout') .set('Cookie', sessionCookie.cookieString()) .expect(302); @@ -404,7 +404,7 @@ export default function({ getService }: FtrProviderContext) { // Tokens that were stored in the previous cookie should be invalidated as well and old // session cookie should not allow API access. const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(400); @@ -417,7 +417,7 @@ export default function({ getService }: FtrProviderContext) { }); it('should redirect to home page if session cookie is not provided', async () => { - const logoutResponse = await supertest.get('/api/security/v1/logout').expect(302); + const logoutResponse = await supertest.get('/api/security/logout').expect(302); expect(logoutResponse.headers['set-cookie']).to.be(undefined); expect(logoutResponse.headers.location).to.be('/'); @@ -425,7 +425,7 @@ export default function({ getService }: FtrProviderContext) { it('should reject AJAX requests', async () => { const ajaxResponse = await supertest - .get('/api/security/v1/logout') + .get('/api/security/logout') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(400); @@ -441,7 +441,7 @@ export default function({ getService }: FtrProviderContext) { it('should invalidate access token on IdP initiated logout', async () => { const logoutRequest = await createLogoutRequest({ sessionIndex: idpSessionIndex }); const logoutResponse = await supertest - .get(`/api/security/v1/logout?${querystring.stringify(logoutRequest)}`) + .get(`/api/security/logout?${querystring.stringify(logoutRequest)}`) .set('Cookie', sessionCookie.cookieString()) .expect(302); @@ -462,7 +462,7 @@ export default function({ getService }: FtrProviderContext) { // Tokens that were stored in the previous cookie should be invalidated as well and old session // cookie should not allow API access. const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(400); @@ -477,7 +477,7 @@ export default function({ getService }: FtrProviderContext) { it('should invalidate access token on IdP initiated logout even if there is no Kibana session', async () => { const logoutRequest = await createLogoutRequest({ sessionIndex: idpSessionIndex }); const logoutResponse = await supertest - .get(`/api/security/v1/logout?${querystring.stringify(logoutRequest)}`) + .get(`/api/security/logout?${querystring.stringify(logoutRequest)}`) .expect(302); expect(logoutResponse.headers['set-cookie']).to.be(undefined); @@ -490,7 +490,7 @@ export default function({ getService }: FtrProviderContext) { // IdP session id (encoded in SAML LogoutRequest) even if Kibana doesn't provide them and session // cookie with these tokens should not allow API access. const apiResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(400); @@ -548,7 +548,7 @@ export default function({ getService }: FtrProviderContext) { // This api call should succeed and automatically refresh token. Returned cookie will contain // the new access and refresh token pair. const firstResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -562,7 +562,7 @@ export default function({ getService }: FtrProviderContext) { // Request with old cookie should reuse the same refresh token if within 60 seconds. // Returned cookie will contain the same new access and refresh token pairs as the first request const secondResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200); @@ -577,14 +577,14 @@ export default function({ getService }: FtrProviderContext) { // The first new cookie with fresh pair of access and refresh tokens should work. await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', firstNewCookie.cookieString()) .expect(200); // The second new cookie with fresh pair of access and refresh tokens should work. await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', secondNewCookie.cookieString()) .expect(200); @@ -701,7 +701,7 @@ export default function({ getService }: FtrProviderContext) { // Tokens from old cookie are invalidated. const rejectedResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', existingSessionCookie.cookieString()) .expect(400); @@ -712,7 +712,7 @@ export default function({ getService }: FtrProviderContext) { // Only tokens from new session are valid. const acceptedResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', newSessionCookie.cookieString()) .expect(200); @@ -737,7 +737,7 @@ export default function({ getService }: FtrProviderContext) { // Tokens from old cookie are invalidated. const rejectedResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', existingSessionCookie.cookieString()) .expect(400); @@ -748,7 +748,7 @@ export default function({ getService }: FtrProviderContext) { // Only tokens from new session are valid. const acceptedResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', newSessionCookie.cookieString()) .expect(200); diff --git a/x-pack/test/saved_object_api_integration/common/services/legacy_es.js b/x-pack/test/saved_object_api_integration/common/services/legacy_es.js index 94aa6025aa699..9267fa312ed06 100644 --- a/x-pack/test/saved_object_api_integration/common/services/legacy_es.js +++ b/x-pack/test/saved_object_api_integration/common/services/legacy_es.js @@ -8,7 +8,7 @@ import { format as formatUrl } from 'url'; import * as legacyElasticsearch from 'elasticsearch'; -import shieldPlugin from '../../../../legacy/server/lib/esjs_shield_plugin'; +import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch_client_plugin'; export function LegacyEsProvider({ getService }) { const config = getService('config'); @@ -16,6 +16,6 @@ export function LegacyEsProvider({ getService }) { return new legacyElasticsearch.Client({ host: formatUrl(config.get('servers.elasticsearch')), requestTimeout: config.get('timeouts.esRequestTimeout'), - plugins: [shieldPlugin], + plugins: [elasticsearchClientPlugin], }); } diff --git a/x-pack/test/spaces_api_integration/common/services/legacy_es.js b/x-pack/test/spaces_api_integration/common/services/legacy_es.js index 5e8137f0d11b5..5862fe877ba5c 100644 --- a/x-pack/test/spaces_api_integration/common/services/legacy_es.js +++ b/x-pack/test/spaces_api_integration/common/services/legacy_es.js @@ -8,7 +8,7 @@ import { format as formatUrl } from 'url'; import * as legacyElasticsearch from 'elasticsearch'; -import shieldPlugin from '../../../../legacy/server/lib/esjs_shield_plugin'; +import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch_client_plugin'; export function LegacyEsProvider({ getService }) { const config = getService('config'); @@ -16,6 +16,6 @@ export function LegacyEsProvider({ getService }) { return new legacyElasticsearch.Client({ host: formatUrl(config.get('servers.elasticsearch')), requestTimeout: config.get('timeouts.esRequestTimeout'), - plugins: [shieldPlugin] + plugins: [elasticsearchClientPlugin] }); } diff --git a/x-pack/test/token_api_integration/auth/header.js b/x-pack/test/token_api_integration/auth/header.js index 4b27fd5db3166..1c88f28a65541 100644 --- a/x-pack/test/token_api_integration/auth/header.js +++ b/x-pack/test/token_api_integration/auth/header.js @@ -25,7 +25,7 @@ export default function ({ getService }) { const token = await createToken(); await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('authorization', `Bearer ${token}`) .expect(200); @@ -36,14 +36,14 @@ export default function ({ getService }) { // try it once await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('authorization', `Bearer ${token}`) .expect(200); // try it again to verity it isn't invalidated after a single request await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('authorization', `Bearer ${token}`) .expect(200); @@ -51,7 +51,7 @@ export default function ({ getService }) { it('rejects invalid access token via authorization Bearer header', async () => { await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('authorization', 'Bearer notreal') .expect(401); @@ -67,7 +67,7 @@ export default function ({ getService }) { await new Promise(resolve => setTimeout(() => resolve(), 20000)); await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('authorization', `Bearer ${token}`) .expect(401); diff --git a/x-pack/test/token_api_integration/auth/login.js b/x-pack/test/token_api_integration/auth/login.js index 2e6a2e2f81e4f..aba7e3852aa1f 100644 --- a/x-pack/test/token_api_integration/auth/login.js +++ b/x-pack/test/token_api_integration/auth/login.js @@ -17,7 +17,7 @@ export default function ({ getService }) { describe('login', () => { it('accepts valid login credentials as 204 status', async () => { await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'true') .send({ username: 'elastic', password: 'changeme' }) .expect(204); @@ -25,7 +25,7 @@ export default function ({ getService }) { it('sets HttpOnly cookie with valid login', async () => { const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'true') .send({ username: 'elastic', password: 'changeme' }) .expect(204); @@ -42,7 +42,7 @@ export default function ({ getService }) { it('rejects without kbn-xsrf header as 400 status even if credentials are valid', async () => { const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .send({ username: 'elastic', password: 'changeme' }) .expect(400); @@ -53,7 +53,7 @@ export default function ({ getService }) { it('rejects without credentials as 400 status', async () => { const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'true') .expect(400); @@ -64,7 +64,7 @@ export default function ({ getService }) { it('rejects without password as 400 status', async () => { const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'true') .send({ username: 'elastic' }) .expect(400); @@ -76,7 +76,7 @@ export default function ({ getService }) { it('rejects without username as 400 status', async () => { const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'true') .send({ password: 'changme' }) .expect(400); @@ -88,7 +88,7 @@ export default function ({ getService }) { it('rejects invalid credentials as 401 status', async () => { const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'true') .send({ username: 'elastic', password: 'notvalidpassword' }) .expect(401); diff --git a/x-pack/test/token_api_integration/auth/logout.js b/x-pack/test/token_api_integration/auth/logout.js index 9063488681958..fa7a0606c3770 100644 --- a/x-pack/test/token_api_integration/auth/logout.js +++ b/x-pack/test/token_api_integration/auth/logout.js @@ -16,7 +16,7 @@ export default function ({ getService }) { async function createSessionCookie() { const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'true') .send({ username: 'elastic', password: 'changeme' }); @@ -33,7 +33,7 @@ export default function ({ getService }) { const cookie = await createSessionCookie(); await supertest - .get('/api/security/v1/logout') + .get('/api/security/logout') .set('cookie', cookie.cookieString()) .expect(302) .expect('location', '/login?msg=LOGGED_OUT'); @@ -43,7 +43,7 @@ export default function ({ getService }) { const cookie = await createSessionCookie(); const response = await supertest - .get('/api/security/v1/logout') + .get('/api/security/logout') .set('cookie', cookie.cookieString()); const newCookie = extractSessionCookie(response); @@ -60,12 +60,12 @@ export default function ({ getService }) { // destroy it await supertest - .get('/api/security/v1/logout') + .get('/api/security/logout') .set('cookie', cookie.cookieString()); // verify that the cookie no longer works await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('cookie', cookie.cookieString()) .expect(400); diff --git a/x-pack/test/token_api_integration/auth/session.js b/x-pack/test/token_api_integration/auth/session.js index 8a9f1d7a3f229..6e8e8c01f3da6 100644 --- a/x-pack/test/token_api_integration/auth/session.js +++ b/x-pack/test/token_api_integration/auth/session.js @@ -19,7 +19,7 @@ export default function ({ getService }) { async function createSessionCookie() { const response = await supertest - .post('/api/security/v1/login') + .post('/internal/security/login') .set('kbn-xsrf', 'true') .send({ username: 'elastic', password: 'changeme' }); @@ -36,7 +36,7 @@ export default function ({ getService }) { const cookie = await createSessionCookie(); await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('cookie', cookie.cookieString()) .expect(200); @@ -47,14 +47,14 @@ export default function ({ getService }) { // try it once await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('cookie', cookie.cookieString()) .expect(200); // try it again to verity it isn't invalidated after a single request await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('cookie', cookie.cookieString()) .expect(200); @@ -85,7 +85,7 @@ export default function ({ getService }) { // This api call should succeed and automatically refresh token. Returned cookie will contain // the new access and refresh token pair. const firstResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('cookie', originalCookie.cookieString()) .expect(200); @@ -96,7 +96,7 @@ export default function ({ getService }) { // Request with old cookie should return another valid cookie we can use to authenticate requests // if it happens within 60 seconds of the refresh token being used const secondResponse = await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('Cookie', originalCookie.cookieString()) .expect(200); @@ -110,14 +110,14 @@ export default function ({ getService }) { // The first new cookie should authenticate a subsequent request await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('Cookie', firstNewCookie.cookieString()) .expect(200); // The second new cookie should authenticate a subsequent request await supertest - .get('/api/security/v1/me') + .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('Cookie', secondNewCookie.cookieString()) .expect(200);