;
+ beforeEach(() => {
+ const [acsRouteConfig, acsRouteHandler] = router.post.mock.calls.find(
+ ([{ path }]) => path === '/internal/security/access_agreement/acknowledge'
+ )!;
+
+ license.getFeatures.mockReturnValue({
+ allowAccessAgreement: true,
+ } as SecurityLicenseFeatures);
+
+ routeConfig = acsRouteConfig;
+ routeHandler = acsRouteHandler;
+ });
+
+ it('correctly defines route.', () => {
+ expect(routeConfig.options).toBeUndefined();
+ expect(routeConfig.validate).toBe(false);
+ });
+
+ it(`returns 403 if current license doesn't allow access agreement acknowledgement.`, async () => {
+ license.getFeatures.mockReturnValue({
+ allowAccessAgreement: false,
+ } as SecurityLicenseFeatures);
+
+ const request = httpServerMock.createKibanaRequest();
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 403,
+ payload: { message: `Current license doesn't support access agreement.` },
+ options: { body: { message: `Current license doesn't support access agreement.` } },
+ });
+ });
+
+ it('returns 500 if acknowledge throws unhandled exception.', async () => {
+ const unhandledException = new Error('Something went wrong.');
+ authc.acknowledgeAccessAgreement.mockRejectedValue(unhandledException);
+
+ const request = httpServerMock.createKibanaRequest();
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 500,
+ payload: 'Internal Error',
+ options: {},
+ });
+ });
+
+ it('returns 204 if successfully acknowledged.', async () => {
+ authc.acknowledgeAccessAgreement.mockResolvedValue(undefined);
+
+ const request = httpServerMock.createKibanaRequest();
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 204,
+ options: {},
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts
index abab67c9cd1d28..91783140539a5b 100644
--- a/x-pack/plugins/security/server/routes/authentication/common.ts
+++ b/x-pack/plugins/security/server/routes/authentication/common.ts
@@ -18,7 +18,13 @@ import { RouteDefinitionParams } from '..';
/**
* Defines routes that are common to various authentication mechanisms.
*/
-export function defineCommonRoutes({ router, authc, basePath, logger }: RouteDefinitionParams) {
+export function defineCommonRoutes({
+ router,
+ authc,
+ basePath,
+ license,
+ 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(
@@ -135,4 +141,26 @@ export function defineCommonRoutes({ router, authc, basePath, logger }: RouteDef
}
})
);
+
+ router.post(
+ { path: '/internal/security/access_agreement/acknowledge', validate: false },
+ createLicensedRouteHandler(async (context, request, response) => {
+ // If license doesn't allow access agreement we shouldn't handle request.
+ if (!license.getFeatures().allowAccessAgreement) {
+ logger.warn(`Attempted to acknowledge access agreement when license doesn't allow it.`);
+ return response.forbidden({
+ body: { message: `Current license doesn't support access agreement.` },
+ });
+ }
+
+ try {
+ await authc.acknowledgeAccessAgreement(request);
+ } catch (err) {
+ logger.error(err);
+ return response.internalError();
+ }
+
+ return response.noContent();
+ })
+ );
}
diff --git a/x-pack/plugins/security/server/routes/licensed_route_handler.ts b/x-pack/plugins/security/server/routes/licensed_route_handler.ts
index b113b2ca59e3ef..d8c212aa2d2174 100644
--- a/x-pack/plugins/security/server/routes/licensed_route_handler.ts
+++ b/x-pack/plugins/security/server/routes/licensed_route_handler.ts
@@ -4,10 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { RequestHandler } from 'kibana/server';
+import { KibanaResponseFactory, RequestHandler, RouteMethod } from 'kibana/server';
-export const createLicensedRouteHandler = (handler: RequestHandler
) => {
- const licensedRouteHandler: RequestHandler
= (context, request, responseToolkit) => {
+export const createLicensedRouteHandler = <
+ P,
+ Q,
+ B,
+ M extends RouteMethod,
+ R extends KibanaResponseFactory
+>(
+ handler: RequestHandler
+) => {
+ const licensedRouteHandler: RequestHandler
= (
+ context,
+ request,
+ responseToolkit
+ ) => {
const { license } = context.licensing;
const licenseCheck = license.check('security', 'basic');
if (licenseCheck.state === 'unavailable' || licenseCheck.state === 'invalid') {
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
index fd05821f9d5206..c163ff4e256cd2 100644
--- a/x-pack/plugins/security/server/routes/users/change_password.test.ts
+++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts
@@ -53,7 +53,7 @@ describe('Change password', () => {
now: Date.now(),
idleTimeoutExpiration: null,
lifespanExpiration: null,
- provider: 'basic',
+ provider: { type: 'basic', name: 'basic' },
});
mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.test.ts b/x-pack/plugins/security/server/routes/views/access_agreement.test.ts
new file mode 100644
index 00000000000000..3d616575b84131
--- /dev/null
+++ b/x-pack/plugins/security/server/routes/views/access_agreement.test.ts
@@ -0,0 +1,177 @@
+/*
+ * 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 {
+ RequestHandler,
+ RouteConfig,
+ kibanaResponseFactory,
+ IRouter,
+ HttpResources,
+ HttpResourcesRequestHandler,
+ RequestHandlerContext,
+} from '../../../../../../src/core/server';
+import { SecurityLicense, SecurityLicenseFeatures } from '../../../common/licensing';
+import { AuthenticationProvider } from '../../../common/types';
+import { ConfigType } from '../../config';
+import { defineAccessAgreementRoutes } from './access_agreement';
+
+import { httpResourcesMock, httpServerMock } from '../../../../../../src/core/server/mocks';
+import { routeDefinitionParamsMock } from '../index.mock';
+import { Authentication } from '../../authentication';
+
+describe('Access agreement view routes', () => {
+ let httpResources: jest.Mocked;
+ let router: jest.Mocked;
+ let config: ConfigType;
+ let authc: jest.Mocked;
+ let license: jest.Mocked;
+ let mockContext: RequestHandlerContext;
+ beforeEach(() => {
+ const routeParamsMock = routeDefinitionParamsMock.create();
+ router = routeParamsMock.router;
+ httpResources = routeParamsMock.httpResources;
+ authc = routeParamsMock.authc;
+ config = routeParamsMock.config;
+ license = routeParamsMock.license;
+
+ license.getFeatures.mockReturnValue({
+ allowAccessAgreement: true,
+ } as SecurityLicenseFeatures);
+
+ mockContext = ({
+ licensing: {
+ license: { check: jest.fn().mockReturnValue({ check: 'valid' }) },
+ },
+ } as unknown) as RequestHandlerContext;
+
+ defineAccessAgreementRoutes(routeParamsMock);
+ });
+
+ describe('View route', () => {
+ let routeHandler: HttpResourcesRequestHandler;
+ let routeConfig: RouteConfig;
+ beforeEach(() => {
+ const [viewRouteConfig, viewRouteHandler] = httpResources.register.mock.calls.find(
+ ([{ path }]) => path === '/security/access_agreement'
+ )!;
+
+ routeConfig = viewRouteConfig;
+ routeHandler = viewRouteHandler;
+ });
+
+ it('correctly defines route.', () => {
+ expect(routeConfig.options).toBeUndefined();
+ expect(routeConfig.validate).toBe(false);
+ });
+
+ it('does not render view if current license does not allow access agreement.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+ const responseFactory = httpResourcesMock.createResponseFactory();
+
+ license.getFeatures.mockReturnValue({
+ allowAccessAgreement: false,
+ } as SecurityLicenseFeatures);
+
+ await routeHandler(mockContext, request, responseFactory);
+
+ expect(responseFactory.renderCoreApp).not.toHaveBeenCalledWith();
+ expect(responseFactory.forbidden).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders view.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+ const responseFactory = httpResourcesMock.createResponseFactory();
+
+ await routeHandler(mockContext, request, responseFactory);
+
+ expect(responseFactory.renderCoreApp).toHaveBeenCalledWith();
+ });
+ });
+
+ describe('Access agreement state route', () => {
+ let routeHandler: RequestHandler;
+ let routeConfig: RouteConfig;
+ beforeEach(() => {
+ const [loginStateRouteConfig, loginStateRouteHandler] = router.get.mock.calls.find(
+ ([{ path }]) => path === '/internal/security/access_agreement/state'
+ )!;
+
+ routeConfig = loginStateRouteConfig;
+ routeHandler = loginStateRouteHandler;
+ });
+
+ it('correctly defines route.', () => {
+ expect(routeConfig.options).toBeUndefined();
+ expect(routeConfig.validate).toBe(false);
+ });
+
+ it('returns `403` if current license does not allow access agreement.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+
+ license.getFeatures.mockReturnValue({
+ allowAccessAgreement: false,
+ } as SecurityLicenseFeatures);
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 403,
+ payload: { message: `Current license doesn't support access agreement.` },
+ options: { body: { message: `Current license doesn't support access agreement.` } },
+ });
+ });
+
+ it('returns empty `accessAgreement` if session info is not available.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+
+ authc.getSessionInfo.mockResolvedValue(null);
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ options: { body: { accessAgreement: '' } },
+ payload: { accessAgreement: '' },
+ status: 200,
+ });
+ });
+
+ it('returns non-empty `accessAgreement` only if it is configured.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+
+ config.authc = routeDefinitionParamsMock.create({
+ authc: {
+ providers: {
+ basic: { basic1: { order: 0 } },
+ saml: {
+ saml1: {
+ order: 1,
+ realm: 'realm1',
+ accessAgreement: { message: 'Some access agreement' },
+ },
+ },
+ },
+ },
+ }).config.authc;
+
+ const cases: Array<[AuthenticationProvider, string]> = [
+ [{ type: 'basic', name: 'basic1' }, ''],
+ [{ type: 'saml', name: 'saml1' }, 'Some access agreement'],
+ [{ type: 'unknown-type', name: 'unknown-name' }, ''],
+ ];
+
+ for (const [sessionProvider, expectedAccessAgreement] of cases) {
+ authc.getSessionInfo.mockResolvedValue({
+ now: Date.now(),
+ idleTimeoutExpiration: null,
+ lifespanExpiration: null,
+ provider: sessionProvider,
+ });
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ options: { body: { accessAgreement: expectedAccessAgreement } },
+ payload: { accessAgreement: expectedAccessAgreement },
+ status: 200,
+ });
+ }
+ });
+ });
+});
diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.ts b/x-pack/plugins/security/server/routes/views/access_agreement.ts
new file mode 100644
index 00000000000000..49e1ff42a28a2a
--- /dev/null
+++ b/x-pack/plugins/security/server/routes/views/access_agreement.ts
@@ -0,0 +1,64 @@
+/*
+ * 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 { ConfigType } from '../../config';
+import { createLicensedRouteHandler } from '../licensed_route_handler';
+import { RouteDefinitionParams } from '..';
+
+/**
+ * Defines routes required for the Access Agreement view.
+ */
+export function defineAccessAgreementRoutes({
+ authc,
+ httpResources,
+ license,
+ config,
+ router,
+ logger,
+}: RouteDefinitionParams) {
+ // If license doesn't allow access agreement we shouldn't handle request.
+ const canHandleRequest = () => license.getFeatures().allowAccessAgreement;
+
+ httpResources.register(
+ { path: '/security/access_agreement', validate: false },
+ createLicensedRouteHandler(async (context, request, response) =>
+ canHandleRequest()
+ ? response.renderCoreApp()
+ : response.forbidden({
+ body: { message: `Current license doesn't support access agreement.` },
+ })
+ )
+ );
+
+ router.get(
+ { path: '/internal/security/access_agreement/state', validate: false },
+ createLicensedRouteHandler(async (context, request, response) => {
+ if (!canHandleRequest()) {
+ return response.forbidden({
+ body: { message: `Current license doesn't support access agreement.` },
+ });
+ }
+
+ // It's not guaranteed that we'll have session for the authenticated user (e.g. when user is
+ // authenticated with the help of HTTP authentication), that means we should safely check if
+ // we have it and can get a corresponding configuration.
+ try {
+ const session = await authc.getSessionInfo(request);
+ const accessAgreement =
+ (session &&
+ config.authc.providers[
+ session.provider.type as keyof ConfigType['authc']['providers']
+ ]?.[session.provider.name]?.accessAgreement?.message) ||
+ '';
+
+ return response.ok({ body: { accessAgreement } });
+ } catch (err) {
+ logger.error(err);
+ return response.internalError();
+ }
+ })
+ );
+}
diff --git a/x-pack/plugins/security/server/routes/views/index.test.ts b/x-pack/plugins/security/server/routes/views/index.test.ts
index a8e7e905b119af..7cddef9bf2b982 100644
--- a/x-pack/plugins/security/server/routes/views/index.test.ts
+++ b/x-pack/plugins/security/server/routes/views/index.test.ts
@@ -20,15 +20,18 @@ describe('View routes', () => {
expect(routeParamsMock.httpResources.register.mock.calls.map(([{ path }]) => path))
.toMatchInlineSnapshot(`
Array [
+ "/security/access_agreement",
"/security/account",
"/security/logged_out",
"/logout",
"/security/overwritten_session",
]
`);
- expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(
- `Array []`
- );
+ expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(`
+ Array [
+ "/internal/security/access_agreement/state",
+ ]
+ `);
});
it('registers Login routes if `basic` provider is enabled', () => {
@@ -43,6 +46,7 @@ describe('View routes', () => {
.toMatchInlineSnapshot(`
Array [
"/login",
+ "/security/access_agreement",
"/security/account",
"/security/logged_out",
"/logout",
@@ -52,6 +56,7 @@ describe('View routes', () => {
expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(`
Array [
"/internal/security/login_state",
+ "/internal/security/access_agreement/state",
]
`);
});
@@ -68,6 +73,7 @@ describe('View routes', () => {
.toMatchInlineSnapshot(`
Array [
"/login",
+ "/security/access_agreement",
"/security/account",
"/security/logged_out",
"/logout",
@@ -77,6 +83,7 @@ describe('View routes', () => {
expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(`
Array [
"/internal/security/login_state",
+ "/internal/security/access_agreement/state",
]
`);
});
@@ -93,6 +100,7 @@ describe('View routes', () => {
.toMatchInlineSnapshot(`
Array [
"/login",
+ "/security/access_agreement",
"/security/account",
"/security/logged_out",
"/logout",
@@ -102,6 +110,7 @@ describe('View routes', () => {
expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(`
Array [
"/internal/security/login_state",
+ "/internal/security/access_agreement/state",
]
`);
});
diff --git a/x-pack/plugins/security/server/routes/views/index.ts b/x-pack/plugins/security/server/routes/views/index.ts
index 255989dfeb90cd..b9de58d47fe407 100644
--- a/x-pack/plugins/security/server/routes/views/index.ts
+++ b/x-pack/plugins/security/server/routes/views/index.ts
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { defineAccessAgreementRoutes } from './access_agreement';
import { defineAccountManagementRoutes } from './account_management';
import { defineLoggedOutRoutes } from './logged_out';
import { defineLoginRoutes } from './login';
@@ -20,6 +21,7 @@ export function defineViewRoutes(params: RouteDefinitionParams) {
defineLoginRoutes(params);
}
+ defineAccessAgreementRoutes(params);
defineAccountManagementRoutes(params);
defineLoggedOutRoutes(params);
defineLogoutRoutes(params);
diff --git a/x-pack/plugins/security/server/routes/views/logged_out.test.ts b/x-pack/plugins/security/server/routes/views/logged_out.test.ts
index 3ff05d242d9dde..7cb73c49f9cbc8 100644
--- a/x-pack/plugins/security/server/routes/views/logged_out.test.ts
+++ b/x-pack/plugins/security/server/routes/views/logged_out.test.ts
@@ -39,7 +39,7 @@ describe('LoggedOut view routes', () => {
it('redirects user to the root page if they have a session already.', async () => {
authc.getSessionInfo.mockResolvedValue({
- provider: 'basic',
+ provider: { type: 'basic', name: 'basic' },
now: 0,
idleTimeoutExpiration: null,
lifespanExpiration: null,
diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts
index 8bc2bb32325fc0..014ad390a3d53b 100644
--- a/x-pack/plugins/security/server/routes/views/login.test.ts
+++ b/x-pack/plugins/security/server/routes/views/login.test.ts
@@ -163,6 +163,7 @@ describe('Login view routes', () => {
it('returns only required license features.', async () => {
license.getFeatures.mockReturnValue({
+ allowAccessAgreement: true,
allowLogin: true,
allowRbac: false,
allowRoleDocumentLevelSecurity: true,
diff --git a/x-pack/test/api_integration/apis/security/session.ts b/x-pack/test/api_integration/apis/security/session.ts
index ef7e48388ff660..fcdf268ff27b0a 100644
--- a/x-pack/test/api_integration/apis/security/session.ts
+++ b/x-pack/test/api_integration/apis/security/session.ts
@@ -56,7 +56,7 @@ export default function({ getService }: FtrProviderContext) {
expect(body.now).to.be.a('number');
expect(body.idleTimeoutExpiration).to.be.a('number');
expect(body.lifespanExpiration).to.be(null);
- expect(body.provider).to.be('basic');
+ expect(body.provider).to.eql({ type: 'basic', name: 'basic' });
});
it('should not extend the session', async () => {