;
/** @internal */
protected readonly [requestSymbol]!: Request;
@@ -159,6 +169,7 @@ export class CoreKibanaRequest<
this.id = appState?.requestId ?? uuidv4();
this.uuid = appState?.requestUuid ?? uuidv4();
this.rewrittenUrl = appState?.rewrittenUrl;
+ this.authzResult = appState?.authzResult;
this.url = request.url ?? new URL('https://fake-request/url');
this.headers = isRealReq ? deepFreeze({ ...request.headers }) : request.headers;
@@ -204,6 +215,7 @@ export class CoreKibanaRequest<
isAuthenticated: this.auth.isAuthenticated,
},
route: this.route,
+ authzResult: this.authzResult,
};
}
@@ -256,6 +268,7 @@ export class CoreKibanaRequest<
true, // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8
access: this.getAccess(request),
tags: request.route?.settings?.tags || [],
+ security: this.getSecurity(request),
timeout: {
payload: payloadTimeout,
idleSocket: socketTimeout === 0 ? undefined : socketTimeout,
@@ -277,6 +290,13 @@ export class CoreKibanaRequest<
};
}
+ private getSecurity(request: RawRequest): RouteSecurity | undefined {
+ const securityConfig = ((request.route?.settings as RouteOptions)?.app as KibanaRouteOptions)
+ ?.security;
+
+ return isRouteSecurityGetter(securityConfig) ? securityConfig(request) : securityConfig;
+ }
+
/** set route access to internal if not declared */
private getAccess(request: RawRequest): 'internal' | 'public' {
return (
@@ -289,6 +309,12 @@ export class CoreKibanaRequest<
return true;
}
+ const security = this.getSecurity(request);
+
+ if (security?.authc !== undefined) {
+ return security.authc?.enabled ?? true;
+ }
+
const authOptions = request.route.settings.auth;
if (typeof authOptions === 'object') {
// 'try' is used in the legacy platform
@@ -368,6 +394,7 @@ function isCompleted(request: Request) {
*/
function sanitizeRequest(req: Request): { query: unknown; params: unknown; body: unknown } {
const { [ELASTIC_INTERNAL_ORIGIN_QUERY_PARAM]: __, ...query } = req.query ?? {};
+
return {
query,
params: req.params,
diff --git a/packages/core/http/core-http-router-server-internal/src/route.ts b/packages/core/http/core-http-router-server-internal/src/route.ts
index e494dfb66b7aa..6faae2c1816b9 100644
--- a/packages/core/http/core-http-router-server-internal/src/route.ts
+++ b/packages/core/http/core-http-router-server-internal/src/route.ts
@@ -7,8 +7,17 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
-import type { RouteMethod, SafeRouteMethod } from '@kbn/core-http-server';
+import type { RouteMethod, SafeRouteMethod, RouteConfig } from '@kbn/core-http-server';
+import type { RouteSecurityGetter, RouteSecurity } from '@kbn/core-http-server';
export function isSafeMethod(method: RouteMethod): method is SafeRouteMethod {
return method === 'get' || method === 'options';
}
+
+/** @interval */
+export type InternalRouteConfig = Omit<
+ RouteConfig
,
+ 'security'
+> & {
+ security?: RouteSecurityGetter | RouteSecurity;
+};
diff --git a/packages/core/http/core-http-router-server-internal/src/router.test.ts b/packages/core/http/core-http-router-server-internal/src/router.test.ts
index 18589d5d39d52..65f5b41f91fba 100644
--- a/packages/core/http/core-http-router-server-internal/src/router.test.ts
+++ b/packages/core/http/core-http-router-server-internal/src/router.test.ts
@@ -232,6 +232,47 @@ describe('Router', () => {
);
});
+ it('throws if enabled security config is not valid', () => {
+ const router = new Router('', logger, enhanceWithContext, routerOptions);
+ expect(() =>
+ router.get(
+ {
+ path: '/',
+ validate: false,
+ security: {
+ authz: {
+ requiredPrivileges: [],
+ },
+ },
+ },
+ (context, req, res) => res.ok({})
+ )
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.requiredPrivileges]: array size is [0], but cannot be smaller than [1]"`
+ );
+ });
+
+ it('throws if disabled security config does not provide opt-out reason', () => {
+ const router = new Router('', logger, enhanceWithContext, routerOptions);
+ expect(() =>
+ router.get(
+ {
+ path: '/',
+ validate: false,
+ security: {
+ // @ts-expect-error
+ authz: {
+ enabled: false,
+ },
+ },
+ },
+ (context, req, res) => res.ok({})
+ )
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.reason]: expected value of type [string] but got [undefined]"`
+ );
+ });
+
it('should default `output: "stream" and parse: false` when no body validation is required but not a GET', () => {
const router = new Router('', logger, enhanceWithContext, routerOptions);
router.post({ path: '/', validate: {} }, (context, req, res) => res.ok({}));
diff --git a/packages/core/http/core-http-router-server-internal/src/router.ts b/packages/core/http/core-http-router-server-internal/src/router.ts
index a6f2ccc35f56b..ddfa8980cb8f2 100644
--- a/packages/core/http/core-http-router-server-internal/src/router.ts
+++ b/packages/core/http/core-http-router-server-internal/src/router.ts
@@ -26,9 +26,12 @@ import type {
RequestHandler,
VersionedRouter,
RouteRegistrar,
+ RouteSecurity,
} from '@kbn/core-http-server';
import { isZod } from '@kbn/zod';
import { validBodyOutput, getRequestValidation } from '@kbn/core-http-server';
+import type { RouteSecurityGetter } from '@kbn/core-http-server';
+import type { DeepPartial } from '@kbn/utility-types';
import { RouteValidator } from './validator';
import { CoreVersionedRouter } from './versioned_router';
import { CoreKibanaRequest } from './request';
@@ -38,6 +41,8 @@ import { wrapErrors } from './error_wrapper';
import { Method } from './versioned_router/types';
import { prepareRouteConfigValidation } from './util';
import { stripIllegalHttp2Headers } from './strip_illegal_http2_headers';
+import { validRouteSecurity } from './security_route_config_validator';
+import { InternalRouteConfig } from './route';
export type ContextEnhancer<
P,
@@ -61,7 +66,7 @@ function getRouteFullPath(routerPath: string, routePath: string) {
* undefined.
*/
function routeSchemasFromRouteConfig
(
- route: RouteConfig
,
+ route: InternalRouteConfig
,
routeMethod: RouteMethod
) {
// The type doesn't allow `validate` to be undefined, but it can still
@@ -93,7 +98,7 @@ function routeSchemasFromRouteConfig
(
*/
function validOptions(
method: RouteMethod,
- routeConfig: RouteConfig
+ routeConfig: InternalRouteConfig
) {
const shouldNotHavePayload = ['head', 'get'].includes(method);
const { options = {}, validate } = routeConfig;
@@ -144,10 +149,17 @@ export interface RouterOptions {
export interface InternalRegistrarOptions {
isVersioned: boolean;
}
+/** @internal */
+export type VersionedRouteConfig = Omit<
+ RouteConfig
,
+ 'security'
+> & {
+ security?: RouteSecurityGetter;
+};
/** @internal */
export type InternalRegistrar = (
- route: RouteConfig
,
+ route: InternalRouteConfig
,
handler: RequestHandler
,
internalOpts?: InternalRegistrarOptions
) => ReturnType>;
@@ -186,7 +198,7 @@ export class Router(method: Method) =>
(
- route: RouteConfig
,
+ route: InternalRouteConfig
,
handler: RequestHandler
,
internalOptions: { isVersioned: boolean } = { isVersioned: false }
) => {
@@ -204,6 +216,10 @@ export class Router, route.options),
/** Below is added for introspection */
validationSchemas: route.validate,
isVersioned: internalOptions.isVersioned,
@@ -260,7 +276,12 @@ export class Router;
const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit);
try {
- kibanaRequest = CoreKibanaRequest.from(request, routeSchemas);
+ kibanaRequest = CoreKibanaRequest.from(request, routeSchemas) as KibanaRequest<
+ P,
+ Q,
+ B,
+ typeof request.method
+ >;
} catch (error) {
this.logError('400 Bad Request', 400, { request, error });
return hapiResponseAdapter.toBadRequest(error.message);
diff --git a/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.test.ts b/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.test.ts
new file mode 100644
index 0000000000000..d130bfdce9fb5
--- /dev/null
+++ b/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.test.ts
@@ -0,0 +1,279 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { validRouteSecurity } from './security_route_config_validator';
+
+describe('RouteSecurity validation', () => {
+ it('should pass validation for valid route security with authz enabled and valid required privileges', () => {
+ expect(() =>
+ validRouteSecurity({
+ authz: {
+ requiredPrivileges: ['read', { anyRequired: ['write', 'admin'] }],
+ },
+ authc: {
+ enabled: 'optional',
+ },
+ })
+ ).not.toThrow();
+ });
+
+ it('should pass validation for valid route security with authz disabled', () => {
+ expect(() =>
+ validRouteSecurity({
+ authz: {
+ enabled: false,
+ reason: 'Authorization is disabled',
+ },
+ authc: {
+ enabled: true,
+ },
+ })
+ ).not.toThrow();
+ });
+
+ it('should fail validation when authz is empty', () => {
+ const routeSecurity = {
+ authz: {},
+ authc: {
+ enabled: true,
+ },
+ };
+
+ expect(() => validRouteSecurity(routeSecurity)).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.requiredPrivileges]: expected value of type [array] but got [undefined]"`
+ );
+ });
+
+ it('should fail when requiredPrivileges include an empty privilege set', () => {
+ const routeSecurity = {
+ authz: {
+ requiredPrivileges: [{}],
+ },
+ };
+
+ expect(() => validRouteSecurity(routeSecurity)).toThrowErrorMatchingInlineSnapshot(`
+ "[authz.requiredPrivileges.0]: types that failed validation:
+ - [authz.requiredPrivileges.0.0]: either anyRequired or allRequired must be specified
+ - [authz.requiredPrivileges.0.1]: expected value of type [string] but got [Object]"
+ `);
+ });
+
+ it('should fail validation when requiredPrivileges array is empty', () => {
+ const routeSecurity = {
+ authz: {
+ requiredPrivileges: [],
+ },
+ authc: {
+ enabled: true,
+ },
+ };
+
+ expect(() => validRouteSecurity(routeSecurity)).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.requiredPrivileges]: array size is [0], but cannot be smaller than [1]"`
+ );
+ });
+
+ it('should fail validation when anyRequired array is empty', () => {
+ const routeSecurity = {
+ authz: {
+ requiredPrivileges: [{ anyRequired: [] }],
+ },
+ authc: {
+ enabled: true,
+ },
+ };
+
+ expect(() => validRouteSecurity(routeSecurity)).toThrowErrorMatchingInlineSnapshot(`
+ "[authz.requiredPrivileges.0]: types that failed validation:
+ - [authz.requiredPrivileges.0.0.anyRequired]: array size is [0], but cannot be smaller than [2]
+ - [authz.requiredPrivileges.0.1]: expected value of type [string] but got [Object]"
+ `);
+ });
+
+ it('should fail validation when anyRequired array is of size 1', () => {
+ const routeSecurity = {
+ authz: {
+ requiredPrivileges: [{ anyRequired: ['privilege-1'], allRequired: ['privilege-2'] }],
+ },
+ authc: {
+ enabled: true,
+ },
+ };
+
+ expect(() => validRouteSecurity(routeSecurity)).toThrowErrorMatchingInlineSnapshot(`
+ "[authz.requiredPrivileges.0]: types that failed validation:
+ - [authz.requiredPrivileges.0.0.anyRequired]: array size is [1], but cannot be smaller than [2]
+ - [authz.requiredPrivileges.0.1]: expected value of type [string] but got [Object]"
+ `);
+ });
+
+ it('should fail validation when allRequired array is empty', () => {
+ const routeSecurity = {
+ authz: {
+ requiredPrivileges: [{ allRequired: [] }],
+ },
+ authc: {
+ enabled: true,
+ },
+ };
+
+ expect(() => validRouteSecurity(routeSecurity)).toThrowErrorMatchingInlineSnapshot(`
+ "[authz.requiredPrivileges.0]: types that failed validation:
+ - [authz.requiredPrivileges.0.0.allRequired]: array size is [0], but cannot be smaller than [1]
+ - [authz.requiredPrivileges.0.1]: expected value of type [string] but got [Object]"
+ `);
+ });
+
+ it('should pass validation with valid privileges in both anyRequired and allRequired', () => {
+ const routeSecurity = {
+ authz: {
+ requiredPrivileges: [
+ { anyRequired: ['privilege1', 'privilege2'], allRequired: ['privilege3', 'privilege4'] },
+ ],
+ },
+ authc: {
+ enabled: true,
+ },
+ };
+
+ expect(() => validRouteSecurity(routeSecurity)).not.toThrow();
+ });
+
+ it('should fail validation when authz is disabled but reason is missing', () => {
+ expect(() =>
+ validRouteSecurity({
+ authz: {
+ enabled: false,
+ },
+ authc: {
+ enabled: true,
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.reason]: expected value of type [string] but got [undefined]"`
+ );
+ });
+
+ it('should fail validation when authc is disabled but reason is missing', () => {
+ const routeSecurity = {
+ authz: {
+ requiredPrivileges: ['read'],
+ },
+ authc: {
+ enabled: false,
+ },
+ };
+
+ expect(() => validRouteSecurity(routeSecurity)).toThrowErrorMatchingInlineSnapshot(
+ `"[authc.reason]: expected value of type [string] but got [undefined]"`
+ );
+ });
+
+ it('should fail validation when authc is provided in multiple configs', () => {
+ const routeSecurity = {
+ authz: {
+ requiredPrivileges: ['read'],
+ },
+ authc: {
+ enabled: false,
+ },
+ };
+
+ expect(() =>
+ validRouteSecurity(routeSecurity, { authRequired: false })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"Cannot specify both security.authc and options.authRequired"`
+ );
+ });
+
+ it('should pass validation when authc is optional', () => {
+ expect(() =>
+ validRouteSecurity({
+ authz: {
+ requiredPrivileges: ['read'],
+ },
+ authc: {
+ enabled: 'optional',
+ },
+ })
+ ).not.toThrow();
+ });
+
+ it('should pass validation when authc is disabled', () => {
+ const routeSecurity = {
+ authz: {
+ requiredPrivileges: ['read'],
+ },
+ authc: {
+ enabled: false,
+ reason: 'Authentication is disabled',
+ },
+ };
+
+ expect(() => validRouteSecurity(routeSecurity)).not.toThrow();
+ });
+
+ it('should fail validation when anyRequired and allRequired have the same values', () => {
+ const invalidRouteSecurity = {
+ authz: {
+ requiredPrivileges: [
+ { anyRequired: ['privilege1', 'privilege2'], allRequired: ['privilege1'] },
+ ],
+ },
+ };
+
+ expect(() => validRouteSecurity(invalidRouteSecurity)).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.requiredPrivileges]: anyRequired and allRequired cannot have the same values: [privilege1]"`
+ );
+ });
+
+ it('should fail validation when anyRequired and allRequired have the same values in multiple entries', () => {
+ const invalidRouteSecurity = {
+ authz: {
+ requiredPrivileges: [
+ { anyRequired: ['privilege1', 'privilege2'], allRequired: ['privilege4'] },
+ { anyRequired: ['privilege3', 'privilege5'], allRequired: ['privilege2'] },
+ ],
+ },
+ };
+
+ expect(() => validRouteSecurity(invalidRouteSecurity)).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.requiredPrivileges]: anyRequired and allRequired cannot have the same values: [privilege2]"`
+ );
+ });
+
+ it('should fail validation when anyRequired has duplicate entries', () => {
+ const invalidRouteSecurity = {
+ authz: {
+ requiredPrivileges: [
+ { anyRequired: ['privilege1', 'privilege1'], allRequired: ['privilege4'] },
+ ],
+ },
+ };
+
+ expect(() => validRouteSecurity(invalidRouteSecurity)).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.requiredPrivileges]: anyRequired privileges must contain unique values"`
+ );
+ });
+
+ it('should fail validation when anyRequired has duplicates in multiple privilege entries', () => {
+ const invalidRouteSecurity = {
+ authz: {
+ requiredPrivileges: [
+ { anyRequired: ['privilege1', 'privilege1'], allRequired: ['privilege4'] },
+ { anyRequired: ['privilege1', 'privilege1'] },
+ ],
+ },
+ };
+
+ expect(() => validRouteSecurity(invalidRouteSecurity)).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.requiredPrivileges]: anyRequired privileges must contain unique values"`
+ );
+ });
+});
diff --git a/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.ts b/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.ts
new file mode 100644
index 0000000000000..d74f41d3157b4
--- /dev/null
+++ b/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.ts
@@ -0,0 +1,116 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { schema } from '@kbn/config-schema';
+import type { RouteSecurity, RouteConfigOptions } from '@kbn/core-http-server';
+import type { DeepPartial } from '@kbn/utility-types';
+
+const privilegeSetSchema = schema.object(
+ {
+ anyRequired: schema.maybe(schema.arrayOf(schema.string(), { minSize: 2 })),
+ allRequired: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
+ },
+ {
+ validate: (value) => {
+ if (!value.anyRequired && !value.allRequired) {
+ return 'either anyRequired or allRequired must be specified';
+ }
+ },
+ }
+);
+
+const requiredPrivilegesSchema = schema.arrayOf(
+ schema.oneOf([privilegeSetSchema, schema.string()]),
+ {
+ validate: (value) => {
+ const anyRequired: string[] = [];
+ const allRequired: string[] = [];
+
+ if (!Array.isArray(value)) {
+ return undefined;
+ }
+
+ value.forEach((privilege) => {
+ if (typeof privilege === 'string') {
+ allRequired.push(privilege);
+ } else {
+ if (privilege.anyRequired) {
+ anyRequired.push(...privilege.anyRequired);
+ }
+ if (privilege.allRequired) {
+ allRequired.push(...privilege.allRequired);
+ }
+ }
+ });
+
+ if (anyRequired.length && allRequired.length) {
+ for (const privilege of anyRequired) {
+ if (allRequired.includes(privilege)) {
+ return `anyRequired and allRequired cannot have the same values: [${privilege}]`;
+ }
+ }
+ }
+
+ if (anyRequired.length) {
+ const uniquePrivileges = new Set([...anyRequired]);
+
+ if (anyRequired.length !== uniquePrivileges.size) {
+ return 'anyRequired privileges must contain unique values';
+ }
+ }
+ },
+ minSize: 1,
+ }
+);
+
+const authzSchema = schema.object({
+ enabled: schema.maybe(schema.literal(false)),
+ requiredPrivileges: schema.conditional(
+ schema.siblingRef('enabled'),
+ schema.never(),
+ requiredPrivilegesSchema,
+ schema.never()
+ ),
+ reason: schema.conditional(
+ schema.siblingRef('enabled'),
+ schema.never(),
+ schema.never(),
+ schema.string()
+ ),
+});
+
+const authcSchema = schema.object({
+ enabled: schema.oneOf([schema.literal(true), schema.literal('optional'), schema.literal(false)]),
+ reason: schema.conditional(
+ schema.siblingRef('enabled'),
+ schema.literal(false),
+ schema.string(),
+ schema.never()
+ ),
+});
+
+const routeSecuritySchema = schema.object({
+ authz: authzSchema,
+ authc: schema.maybe(authcSchema),
+});
+
+export const validRouteSecurity = (
+ routeSecurity?: DeepPartial,
+ options?: DeepPartial>
+) => {
+ if (!routeSecurity) {
+ return routeSecurity;
+ }
+
+ if (routeSecurity?.authc !== undefined && options?.authRequired !== undefined) {
+ throw new Error('Cannot specify both security.authc and options.authRequired');
+ }
+
+ return routeSecuritySchema.validate(routeSecurity);
+};
diff --git a/packages/core/http/core-http-router-server-internal/src/util.ts b/packages/core/http/core-http-router-server-internal/src/util.ts
index 1a0d9976bbd42..0d1c8abb0e103 100644
--- a/packages/core/http/core-http-router-server-internal/src/util.ts
+++ b/packages/core/http/core-http-router-server-internal/src/util.ts
@@ -11,10 +11,10 @@ import { once } from 'lodash';
import {
isFullValidatorContainer,
type RouteValidatorFullConfigResponse,
- type RouteConfig,
type RouteMethod,
type RouteValidator,
} from '@kbn/core-http-server';
+import type { InternalRouteConfig } from './route';
function isStatusCode(key: string) {
return !isNaN(parseInt(key, 10));
@@ -45,8 +45,8 @@ function prepareValidation(validator: RouteValidator
) {
// Integration tested in ./routes.test.ts
export function prepareRouteConfigValidation
(
- config: RouteConfig
-): RouteConfig
{
+ config: InternalRouteConfig
+): InternalRouteConfig
{
// Calculating schema validation can be expensive so when it is provided lazily
// we only want to instantiate it once. This also provides idempotency guarantees
if (typeof config.validate === 'function') {
diff --git a/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.test.ts b/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.test.ts
index efa5cf5ae23d8..314c25c5a2a1e 100644
--- a/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.test.ts
+++ b/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.test.ts
@@ -13,6 +13,7 @@ import type {
RequestHandler,
RouteConfig,
VersionedRouteValidation,
+ RouteSecurity,
} from '@kbn/core-http-server';
import { Router } from '../router';
import { createFooValidation } from '../router.test.util';
@@ -22,6 +23,7 @@ import { passThroughValidation } from './core_versioned_route';
import { Method } from './types';
import { createRequest } from './core_versioned_route.test.util';
import { isConfigSchema } from '@kbn/config-schema';
+import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
describe('Versioned route', () => {
let router: Router;
@@ -429,4 +431,198 @@ describe('Versioned route', () => {
expect(doNotBypassResponse2.status).toBe(400);
expect(doNotBypassResponse2.payload).toMatch('Please specify a version');
});
+
+ it('can register multiple handlers with different security configurations', () => {
+ const versionedRouter = CoreVersionedRouter.from({ router });
+ const securityConfig1: RouteSecurity = {
+ authz: {
+ requiredPrivileges: ['foo'],
+ },
+ authc: {
+ enabled: 'optional',
+ },
+ };
+ const securityConfig2: RouteSecurity = {
+ authz: {
+ requiredPrivileges: ['foo', 'bar'],
+ },
+ authc: {
+ enabled: true,
+ },
+ };
+ const securityConfig3: RouteSecurity = {
+ authz: {
+ requiredPrivileges: ['foo', 'bar', 'baz'],
+ },
+ };
+ versionedRouter
+ .get({ path: '/test/{id}', access: 'internal' })
+ .addVersion(
+ {
+ version: '1',
+ validate: false,
+ security: securityConfig1,
+ },
+ handlerFn
+ )
+ .addVersion(
+ {
+ version: '2',
+ validate: false,
+ security: securityConfig2,
+ },
+ handlerFn
+ )
+ .addVersion(
+ {
+ version: '3',
+ validate: false,
+ security: securityConfig3,
+ },
+ handlerFn
+ );
+ const routes = versionedRouter.getRoutes();
+ expect(routes).toHaveLength(1);
+ const [route] = routes;
+ expect(route.handlers).toHaveLength(3);
+
+ expect(route.handlers[0].options.security).toStrictEqual(securityConfig1);
+ expect(route.handlers[1].options.security).toStrictEqual(securityConfig2);
+ expect(route.handlers[2].options.security).toStrictEqual(securityConfig3);
+ expect(router.get).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to default security configuration if it is not specified for specific version', () => {
+ const versionedRouter = CoreVersionedRouter.from({ router });
+ const securityConfigDefault: RouteSecurity = {
+ authz: {
+ requiredPrivileges: ['foo', 'bar', 'baz'],
+ },
+ };
+ const securityConfig1: RouteSecurity = {
+ authz: {
+ requiredPrivileges: ['foo'],
+ },
+ authc: {
+ enabled: 'optional',
+ },
+ };
+ const securityConfig2: RouteSecurity = {
+ authz: {
+ requiredPrivileges: ['foo', 'bar'],
+ },
+ authc: {
+ enabled: true,
+ },
+ };
+ const versionedRoute = versionedRouter
+ .get({ path: '/test/{id}', access: 'internal', security: securityConfigDefault })
+ .addVersion(
+ {
+ version: '1',
+ validate: false,
+ security: securityConfig1,
+ },
+ handlerFn
+ )
+ .addVersion(
+ {
+ version: '2',
+ validate: false,
+ security: securityConfig2,
+ },
+ handlerFn
+ )
+ .addVersion(
+ {
+ version: '3',
+ validate: false,
+ },
+ handlerFn
+ );
+ const routes = versionedRouter.getRoutes();
+ expect(routes).toHaveLength(1);
+ const [route] = routes;
+ expect(route.handlers).toHaveLength(3);
+
+ expect(
+ // @ts-expect-error
+ versionedRoute.getSecurity({
+ headers: {},
+ })
+ ).toStrictEqual(securityConfigDefault);
+
+ expect(
+ // @ts-expect-error
+ versionedRoute.getSecurity({
+ headers: { [ELASTIC_HTTP_VERSION_HEADER]: '1' },
+ })
+ ).toStrictEqual(securityConfig1);
+
+ expect(
+ // @ts-expect-error
+ versionedRoute.getSecurity({
+ headers: { [ELASTIC_HTTP_VERSION_HEADER]: '2' },
+ })
+ ).toStrictEqual(securityConfig2);
+
+ expect(
+ // @ts-expect-error
+ versionedRoute.getSecurity({
+ headers: {},
+ })
+ ).toStrictEqual(securityConfigDefault);
+ expect(router.get).toHaveBeenCalledTimes(1);
+ });
+
+ it('validates security configuration', () => {
+ const versionedRouter = CoreVersionedRouter.from({ router });
+ const validSecurityConfig: RouteSecurity = {
+ authz: {
+ requiredPrivileges: ['foo'],
+ },
+ authc: {
+ enabled: 'optional',
+ },
+ };
+
+ expect(() =>
+ versionedRouter.get({
+ path: '/test/{id}',
+ access: 'internal',
+ security: {
+ authz: {
+ requiredPrivileges: [],
+ },
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.requiredPrivileges]: array size is [0], but cannot be smaller than [1]"`
+ );
+
+ const route = versionedRouter.get({
+ path: '/test/{id}',
+ access: 'internal',
+ security: validSecurityConfig,
+ });
+
+ expect(() =>
+ route.addVersion(
+ {
+ version: '1',
+ validate: false,
+ security: {
+ authz: {
+ requiredPrivileges: [{ allRequired: ['foo'], anyRequired: ['bar'] }],
+ },
+ },
+ },
+ handlerFn
+ )
+ ).toThrowErrorMatchingInlineSnapshot(`
+ "[authz.requiredPrivileges.0]: types that failed validation:
+ - [authz.requiredPrivileges.0.0.anyRequired]: array size is [1], but cannot be smaller than [2]
+ - [authz.requiredPrivileges.0.1]: expected value of type [string] but got [Object]"
+ `);
+ });
});
diff --git a/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts b/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts
index 510f713ac6ac4..3ab9af61af628 100644
--- a/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts
+++ b/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts
@@ -23,6 +23,8 @@ import type {
VersionedRouteConfig,
IKibanaResponse,
RouteConfigOptions,
+ RouteSecurityGetter,
+ RouteSecurity,
} from '@kbn/core-http-server';
import type { Mutable } from 'utility-types';
import type { Method, VersionedRouterRoute } from './types';
@@ -37,9 +39,11 @@ import {
removeQueryVersion,
} from './route_version_utils';
import { injectResponseHeaders } from './inject_response_headers';
+import { validRouteSecurity } from '../security_route_config_validator';
import { resolvers } from './handler_resolvers';
import { prepareVersionedRouteValidation, unwrapVersionedResponseBodyValidation } from './util';
+import type { RequestLike } from './route_version_utils';
type Options = AddVersionOpts;
@@ -82,6 +86,7 @@ export class CoreVersionedRoute implements VersionedRoute {
private useDefaultStrategyForPath: boolean;
private isPublic: boolean;
private enableQueryVersion: boolean;
+ private defaultSecurityConfig: RouteSecurity | undefined;
private constructor(
private readonly router: CoreVersionedRouter,
public readonly method: Method,
@@ -91,12 +96,14 @@ export class CoreVersionedRoute implements VersionedRoute {
this.useDefaultStrategyForPath = router.useVersionResolutionStrategyForInternalPaths.has(path);
this.isPublic = this.options.access === 'public';
this.enableQueryVersion = this.options.enableQueryVersion === true;
+ this.defaultSecurityConfig = validRouteSecurity(this.options.security, this.options.options);
this.router.router[this.method](
{
path: this.path,
validate: passThroughValidation,
// @ts-expect-error upgrade typescript v5.1.6
options: this.getRouteConfigOptions(),
+ security: this.getSecurity,
},
this.requestHandler,
{ isVersioned: true }
@@ -122,6 +129,18 @@ export class CoreVersionedRoute implements VersionedRoute {
return this.handlers.size ? '[' + [...this.handlers.keys()].join(', ') + ']' : '';
}
+ private getVersion(req: RequestLike): ApiVersion | undefined {
+ let version;
+ const maybeVersion = readVersion(req, this.enableQueryVersion);
+ if (!maybeVersion && (this.isPublic || this.useDefaultStrategyForPath)) {
+ version = this.getDefaultVersion();
+ } else {
+ version = maybeVersion;
+ }
+
+ return version;
+ }
+
private requestHandler = async (
ctx: RequestHandlerContextBase,
originalReq: KibanaRequest,
@@ -134,14 +153,8 @@ export class CoreVersionedRoute implements VersionedRoute {
});
}
const req = originalReq as Mutable;
- let version: undefined | ApiVersion;
+ const version = this.getVersion(req);
- const maybeVersion = readVersion(req, this.enableQueryVersion);
- if (!maybeVersion && (this.isPublic || this.useDefaultStrategyForPath)) {
- version = this.getDefaultVersion();
- } else {
- version = maybeVersion;
- }
if (!version) {
return res.badRequest({
body: `Please specify a version via ${ELASTIC_HTTP_VERSION_HEADER} header. Available versions: ${this.versionsToString()}`,
@@ -247,6 +260,7 @@ export class CoreVersionedRoute implements VersionedRoute {
public addVersion(options: Options, handler: RequestHandler): VersionedRoute {
this.validateVersion(options.version);
options = prepareVersionedRouteValidation(options);
+
this.handlers.set(options.version, {
fn: handler,
options,
@@ -257,4 +271,10 @@ export class CoreVersionedRoute implements VersionedRoute {
public getHandlers(): Array<{ fn: RequestHandler; options: Options }> {
return [...this.handlers.values()];
}
+
+ public getSecurity: RouteSecurityGetter = (req: RequestLike) => {
+ const version = this.getVersion(req)!;
+
+ return this.handlers.get(version)?.options.security ?? this.defaultSecurityConfig;
+ };
}
diff --git a/packages/core/http/core-http-router-server-internal/src/versioned_router/route_version_utils.ts b/packages/core/http/core-http-router-server-internal/src/versioned_router/route_version_utils.ts
index f8645f947beaf..a6e7c3e31f45f 100644
--- a/packages/core/http/core-http-router-server-internal/src/versioned_router/route_version_utils.ts
+++ b/packages/core/http/core-http-router-server-internal/src/versioned_router/route_version_utils.ts
@@ -54,6 +54,11 @@ type KibanaRequestWithQueryVersion = KibanaRequest<
{ [ELASTIC_HTTP_VERSION_QUERY_PARAM]: unknown }
>;
+export interface RequestLike {
+ headers: KibanaRequest['headers'];
+ query?: KibanaRequest['query'];
+}
+
export function hasQueryVersion(
request: Mutable
): request is Mutable {
@@ -63,13 +68,13 @@ export function removeQueryVersion(request: Mutable {
},
});
});
+
+ describe('validates security config', () => {
+ it('throws error if requiredPrivileges are not provided with enabled authz', () => {
+ expect(() =>
+ prepareVersionedRouteValidation({
+ version: '1',
+ validate: false,
+ security: {
+ authz: {
+ requiredPrivileges: [],
+ },
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.requiredPrivileges]: array size is [0], but cannot be smaller than [1]"`
+ );
+ });
+
+ it('throws error if reason is not provided with disabled authz', () => {
+ expect(() =>
+ prepareVersionedRouteValidation({
+ version: '1',
+ validate: false,
+ security: {
+ // @ts-expect-error
+ authz: {
+ enabled: false,
+ },
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[authz.reason]: expected value of type [string] but got [undefined]"`
+ );
+ });
+
+ it('passes through valid security configuration with enabled authz', () => {
+ expect(
+ prepareVersionedRouteValidation({
+ version: '1',
+ validate: false,
+ security: {
+ authz: {
+ requiredPrivileges: ['privilege-1', { anyRequired: ['privilege-2', 'privilege-3'] }],
+ },
+ },
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "security": Object {
+ "authz": Object {
+ "requiredPrivileges": Array [
+ "privilege-1",
+ Object {
+ "anyRequired": Array [
+ "privilege-2",
+ "privilege-3",
+ ],
+ },
+ ],
+ },
+ },
+ "validate": false,
+ "version": "1",
+ }
+ `);
+ });
+
+ it('passes through valid security configuration with disabled authz', () => {
+ expect(
+ prepareVersionedRouteValidation({
+ version: '1',
+ validate: false,
+ security: {
+ authz: {
+ enabled: false,
+ reason: 'Authorization is disabled',
+ },
+ },
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "security": Object {
+ "authz": Object {
+ "enabled": false,
+ "reason": "Authorization is disabled",
+ },
+ },
+ "validate": false,
+ "version": "1",
+ }
+ `);
+ });
+ });
});
test('unwrapVersionedResponseBodyValidation', () => {
diff --git a/packages/core/http/core-http-router-server-internal/src/versioned_router/util.ts b/packages/core/http/core-http-router-server-internal/src/versioned_router/util.ts
index a5286d70593c4..475f69899d861 100644
--- a/packages/core/http/core-http-router-server-internal/src/versioned_router/util.ts
+++ b/packages/core/http/core-http-router-server-internal/src/versioned_router/util.ts
@@ -16,6 +16,7 @@ import type {
VersionedRouteResponseValidation,
VersionedRouteValidation,
} from '@kbn/core-http-server';
+import { validRouteSecurity } from '../security_route_config_validator';
export function isCustomValidation(
v: VersionedRouteCustomResponseBodyValidation | VersionedResponseBodyValidation
@@ -70,17 +71,18 @@ function prepareValidation(validation: VersionedRouteValidation
): AddVersionOpts {
- if (typeof options.validate === 'function') {
- const validate = options.validate;
- return {
- ...options,
- validate: once(() => prepareValidation(validate())),
- };
- } else if (typeof options.validate === 'object' && options.validate !== null) {
- return {
- ...options,
- validate: prepareValidation(options.validate),
- };
+ const { validate: originalValidate, security, ...rest } = options;
+ let validate = originalValidate;
+
+ if (typeof originalValidate === 'function') {
+ validate = once(() => prepareValidation(originalValidate()));
+ } else if (typeof validate === 'object' && validate !== null) {
+ validate = prepareValidation(validate);
}
- return options;
+
+ return {
+ security: validRouteSecurity(security),
+ validate,
+ ...rest,
+ };
}
diff --git a/packages/core/http/core-http-server-internal/src/http_server.ts b/packages/core/http/core-http-server-internal/src/http_server.ts
index 8c6f745f052c7..4f3c96518cefc 100644
--- a/packages/core/http/core-http-server-internal/src/http_server.ts
+++ b/packages/core/http/core-http-server-internal/src/http_server.ts
@@ -700,6 +700,7 @@ export class HttpServer {
const kibanaRouteOptions: KibanaRouteOptions = {
xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method),
access: route.options.access ?? 'internal',
+ security: route.security,
};
// Log HTTP API target consumer.
optionsLogger.debug(
diff --git a/packages/core/http/core-http-server-internal/src/lifecycle/on_post_auth.ts b/packages/core/http/core-http-server-internal/src/lifecycle/on_post_auth.ts
index ff1dde1c059a0..25424d29b090c 100644
--- a/packages/core/http/core-http-server-internal/src/lifecycle/on_post_auth.ts
+++ b/packages/core/http/core-http-server-internal/src/lifecycle/on_post_auth.ts
@@ -11,6 +11,7 @@ import { Lifecycle, Request, ResponseToolkit as HapiResponseToolkit } from '@hap
import type { Logger } from '@kbn/logging';
import type {
OnPostAuthNextResult,
+ OnPostAuthAuthzResult,
OnPostAuthToolkit,
OnPostAuthResult,
OnPostAuthHandler,
@@ -22,6 +23,7 @@ import {
CoreKibanaRequest,
lifecycleResponseFactory,
} from '@kbn/core-http-router-server-internal';
+import { deepFreeze } from '@kbn/std';
const postAuthResult = {
next(): OnPostAuthResult {
@@ -30,10 +32,16 @@ const postAuthResult = {
isNext(result: OnPostAuthResult): result is OnPostAuthNextResult {
return result && result.type === OnPostAuthResultType.next;
},
+ isAuthzResult(result: OnPostAuthResult): result is OnPostAuthAuthzResult {
+ return result && result.type === OnPostAuthResultType.authzResult;
+ },
};
const toolkit: OnPostAuthToolkit = {
next: postAuthResult.next,
+ authzResultNext: (authzResult: Record) => {
+ return { type: OnPostAuthResultType.authzResult, authzResult };
+ },
};
/**
@@ -49,13 +57,26 @@ export function adoptToHapiOnPostAuthFormat(fn: OnPostAuthHandler, log: Logger)
const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit);
try {
const result = await fn(CoreKibanaRequest.from(request), lifecycleResponseFactory, toolkit);
+
if (isKibanaResponse(result)) {
return hapiResponseAdapter.handle(result);
}
+
if (postAuthResult.isNext(result)) {
return responseToolkit.continue;
}
+ if (postAuthResult.isAuthzResult(result)) {
+ Object.defineProperty(request.app, 'authzResult', {
+ value: deepFreeze(result.authzResult),
+ configurable: false,
+ writable: false,
+ enumerable: false,
+ });
+
+ return responseToolkit.continue;
+ }
+
throw new Error(
`Unexpected result from OnPostAuth. Expected OnPostAuthResult or KibanaResponse, but given: ${result}.`
);
diff --git a/packages/core/http/core-http-server-internal/src/lifecycle_handlers.test.ts b/packages/core/http/core-http-server-internal/src/lifecycle_handlers.test.ts
index f0a1813656a88..bfb132aff32d2 100644
--- a/packages/core/http/core-http-server-internal/src/lifecycle_handlers.test.ts
+++ b/packages/core/http/core-http-server-internal/src/lifecycle_handlers.test.ts
@@ -40,6 +40,7 @@ const createToolkit = (): ToolkitMock => {
render: jest.fn(),
next: jest.fn(),
rewriteUrl: jest.fn(),
+ authzResultNext: jest.fn(),
};
};
diff --git a/packages/core/http/core-http-server-mocks/src/http_server.mocks.ts b/packages/core/http/core-http-server-mocks/src/http_server.mocks.ts
index 760090b39714a..ced67ed273d8e 100644
--- a/packages/core/http/core-http-server-mocks/src/http_server.mocks.ts
+++ b/packages/core/http/core-http-server-mocks/src/http_server.mocks.ts
@@ -34,6 +34,7 @@ const createToolkitMock = (): ToolkitMock => {
render: jest.fn(),
next: jest.fn(),
rewriteUrl: jest.fn(),
+ authzResultNext: jest.fn(),
};
};
diff --git a/packages/core/http/core-http-server-mocks/src/http_service.mock.ts b/packages/core/http/core-http-server-mocks/src/http_service.mock.ts
index 8c71ac7939fbe..4e803ee5f86a8 100644
--- a/packages/core/http/core-http-server-mocks/src/http_service.mock.ts
+++ b/packages/core/http/core-http-server-mocks/src/http_service.mock.ts
@@ -276,6 +276,7 @@ const createOnPreAuthToolkitMock = (): jest.Mocked => ({
const createOnPostAuthToolkitMock = (): jest.Mocked => ({
next: jest.fn(),
+ authzResultNext: jest.fn(),
});
const createOnPreRoutingToolkitMock = (): jest.Mocked => ({
diff --git a/packages/core/http/core-http-server/index.ts b/packages/core/http/core-http-server/index.ts
index 64387e5ca36d7..4ba653fbd534c 100644
--- a/packages/core/http/core-http-server/index.ts
+++ b/packages/core/http/core-http-server/index.ts
@@ -27,6 +27,7 @@ export type {
AuthToolkit,
OnPostAuthHandler,
OnPostAuthNextResult,
+ OnPostAuthAuthzResult,
OnPostAuthToolkit,
OnPostAuthResult,
OnPreAuthHandler,
@@ -107,6 +108,17 @@ export type {
RouteValidatorFullConfigResponse,
LazyValidator,
RouteAccess,
+ AuthzDisabled,
+ AuthzEnabled,
+ RouteAuthz,
+ RouteAuthc,
+ AuthcDisabled,
+ AuthcEnabled,
+ Privilege,
+ PrivilegeSet,
+ RouteSecurity,
+ RouteSecurityGetter,
+ InternalRouteSecurity,
} from './src/router';
export {
validBodyOutput,
diff --git a/packages/core/http/core-http-server/src/lifecycle/index.ts b/packages/core/http/core-http-server/src/lifecycle/index.ts
index ac3b28a1469ed..f999d9daa0042 100644
--- a/packages/core/http/core-http-server/src/lifecycle/index.ts
+++ b/packages/core/http/core-http-server/src/lifecycle/index.ts
@@ -25,6 +25,7 @@ export type {
OnPostAuthNextResult,
OnPostAuthToolkit,
OnPostAuthResult,
+ OnPostAuthAuthzResult,
} from './on_post_auth';
export { OnPostAuthResultType } from './on_post_auth';
diff --git a/packages/core/http/core-http-server/src/lifecycle/on_post_auth.ts b/packages/core/http/core-http-server/src/lifecycle/on_post_auth.ts
index 7f5d3b1a81cb3..2881893906e64 100644
--- a/packages/core/http/core-http-server/src/lifecycle/on_post_auth.ts
+++ b/packages/core/http/core-http-server/src/lifecycle/on_post_auth.ts
@@ -14,6 +14,7 @@ import type { IKibanaResponse, KibanaRequest, LifecycleResponseFactory } from '.
*/
export enum OnPostAuthResultType {
next = 'next',
+ authzResult = 'authzResult',
}
/**
@@ -26,7 +27,15 @@ export interface OnPostAuthNextResult {
/**
* @public
*/
-export type OnPostAuthResult = OnPostAuthNextResult;
+export interface OnPostAuthAuthzResult {
+ type: OnPostAuthResultType.authzResult;
+ authzResult: Record;
+}
+
+/**
+ * @public
+ */
+export type OnPostAuthResult = OnPostAuthNextResult | OnPostAuthAuthzResult;
/**
* @public
@@ -35,6 +44,7 @@ export type OnPostAuthResult = OnPostAuthNextResult;
export interface OnPostAuthToolkit {
/** To pass request to the next handler */
next: () => OnPostAuthResult;
+ authzResultNext: (authzResult: Record) => OnPostAuthAuthzResult;
}
/**
diff --git a/packages/core/http/core-http-server/src/router/index.ts b/packages/core/http/core-http-server/src/router/index.ts
index 89e9a345179b6..c26212fa0de81 100644
--- a/packages/core/http/core-http-server/src/router/index.ts
+++ b/packages/core/http/core-http-server/src/router/index.ts
@@ -29,6 +29,8 @@ export type {
KibanaRequestRouteOptions,
KibanaRequestState,
KibanaRouteOptions,
+ RouteSecurityGetter,
+ InternalRouteSecurity,
} from './request';
export type { RequestHandlerWrapper, RequestHandler } from './request_handler';
export type { RequestHandlerContextBase } from './request_handler_context';
@@ -53,7 +55,17 @@ export type {
RouteContentType,
SafeRouteMethod,
RouteAccess,
+ AuthzDisabled,
+ AuthzEnabled,
+ RouteAuthz,
+ RouteAuthc,
+ AuthcDisabled,
+ AuthcEnabled,
+ RouteSecurity,
+ Privilege,
+ PrivilegeSet,
} from './route';
+
export { validBodyOutput } from './route';
export type {
RouteValidationFunction,
diff --git a/packages/core/http/core-http-server/src/router/request.ts b/packages/core/http/core-http-server/src/router/request.ts
index 9080c1be48c8c..5cb84a21be0c3 100644
--- a/packages/core/http/core-http-server/src/router/request.ts
+++ b/packages/core/http/core-http-server/src/router/request.ts
@@ -13,15 +13,22 @@ import type { Observable } from 'rxjs';
import type { RecursiveReadonly } from '@kbn/utility-types';
import type { HttpProtocol } from '../http_contract';
import type { IKibanaSocket } from './socket';
-import type { RouteMethod, RouteConfigOptions } from './route';
+import type { RouteMethod, RouteConfigOptions, RouteSecurity } from './route';
import type { Headers } from './headers';
+export type RouteSecurityGetter = (request: {
+ headers: KibanaRequest['headers'];
+ query?: KibanaRequest['query'];
+}) => RouteSecurity | undefined;
+export type InternalRouteSecurity = RouteSecurity | RouteSecurityGetter;
+
/**
* @public
*/
export interface KibanaRouteOptions extends RouteOptionsApp {
xsrfRequired: boolean;
access: 'internal' | 'public';
+ security?: InternalRouteSecurity;
}
/**
@@ -32,6 +39,7 @@ export interface KibanaRequestState extends RequestApplicationState {
requestUuid: string;
rewrittenUrl?: URL;
traceId?: string;
+ authzResult?: Record;
measureElu?: () => void;
}
@@ -137,6 +145,12 @@ export interface KibanaRequest<
*/
readonly isFakeRequest: boolean;
+ /**
+ * Authorization check result, passed to the route handler.
+ * Indicates whether the specific privilege was granted or denied.
+ */
+ readonly authzResult?: Record;
+
/**
* An internal request has access to internal routes.
* @note See the {@link KibanaRequestRouteOptions#access} route option.
diff --git a/packages/core/http/core-http-server/src/router/route.ts b/packages/core/http/core-http-server/src/router/route.ts
index c47688b60d3cd..bdf4f9f03c784 100644
--- a/packages/core/http/core-http-server/src/router/route.ts
+++ b/packages/core/http/core-http-server/src/router/route.ts
@@ -111,6 +111,82 @@ export interface RouteConfigOptionsBody {
*/
export type RouteAccess = 'public' | 'internal';
+export type Privilege = string;
+
+/**
+ * A set of privileges that can be used to define complex authorization requirements.
+ *
+ * - `anyRequired`: An array of privileges where at least one must be satisfied to meet the authorization requirement.
+ * - `allRequired`: An array of privileges where all listed privileges must be satisfied to meet the authorization requirement.
+ */
+export interface PrivilegeSet {
+ anyRequired?: Privilege[];
+ allRequired?: Privilege[];
+}
+
+/**
+ * An array representing a combination of simple privileges or complex privilege sets.
+ */
+type Privileges = Array;
+
+/**
+ * Describes the authorization requirements when authorization is enabled.
+ *
+ * - `requiredPrivileges`: An array of privileges or privilege sets that are required for the route.
+ */
+export interface AuthzEnabled {
+ requiredPrivileges: Privileges;
+}
+
+/**
+ * Describes the state when authorization is disabled.
+ *
+ * - `enabled`: A boolean indicating that authorization is not enabled (`false`).
+ * - `reason`: A string explaining why authorization is disabled.
+ */
+export interface AuthzDisabled {
+ enabled: false;
+ reason: string;
+}
+
+/**
+ * Describes the authentication status when authentication is enabled.
+ *
+ * - `enabled`: A boolean or string indicating the authentication status. Can be `true` (authentication required) or `'optional'` (authentication is optional).
+ */
+export interface AuthcEnabled {
+ enabled: true | 'optional';
+}
+
+/**
+ * Describes the state when authentication is disabled.
+ *
+ * - `enabled`: A boolean indicating that authentication is not enabled (`false`).
+ * - `reason`: A string explaining why authentication is disabled.
+ */
+export interface AuthcDisabled {
+ enabled: false;
+ reason: string;
+}
+
+/**
+ * Represents the authentication status for a route. It can either be enabled (`AuthcEnabled`) or disabled (`AuthcDisabled`).
+ */
+export type RouteAuthc = AuthcEnabled | AuthcDisabled;
+
+/**
+ * Represents the authorization status for a route. It can either be enabled (`AuthzEnabled`) or disabled (`AuthzDisabled`).
+ */
+export type RouteAuthz = AuthzEnabled | AuthzDisabled;
+
+/**
+ * Describes the security requirements for a route, including authorization and authentication.
+ */
+export interface RouteSecurity {
+ authz: RouteAuthz;
+ authc?: RouteAuthc;
+}
+
/**
* Additional route options.
* @public
@@ -216,6 +292,12 @@ export interface RouteConfigOptions {
* @example 9.0.0
*/
discontinued?: string;
+ /**
+ * Defines the security requirements for a route, including authorization and authentication.
+ *
+ * @remarks This will be surfaced in OAS documentation.
+ */
+ security?: RouteSecurity;
}
/**
@@ -296,6 +378,11 @@ export interface RouteConfig {
*/
validate: RouteValidator
| (() => RouteValidator
) | false;
+ /**
+ * Defines the security requirements for a route, including authorization and authentication.
+ */
+ security?: RouteSecurity;
+
/**
* Additional route options {@link RouteConfigOptions}.
*/
diff --git a/packages/core/http/core-http-server/src/router/router.ts b/packages/core/http/core-http-server/src/router/router.ts
index e948bb3e26ae6..ba2b5eb906a93 100644
--- a/packages/core/http/core-http-server/src/router/router.ts
+++ b/packages/core/http/core-http-server/src/router/router.ts
@@ -15,6 +15,7 @@ import type { RequestHandler, RequestHandlerWrapper } from './request_handler';
import type { RequestHandlerContextBase } from './request_handler_context';
import type { RouteConfigOptions } from './route';
import { RouteValidator } from './route_validator';
+import { InternalRouteSecurity } from './request';
/**
* Route handler common definition
@@ -125,6 +126,7 @@ export interface RouterRoute {
method: RouteMethod;
path: string;
options: RouteConfigOptions;
+ security?: InternalRouteSecurity;
/**
* @note if providing a function to lazily load your validation schemas assume
* that the function will only be called once.
diff --git a/packages/core/http/core-http-server/src/versioning/types.ts b/packages/core/http/core-http-server/src/versioning/types.ts
index c552abd251a1f..60cbca014e683 100644
--- a/packages/core/http/core-http-server/src/versioning/types.ts
+++ b/packages/core/http/core-http-server/src/versioning/types.ts
@@ -35,10 +35,12 @@ export type VersionedRouteConfig = Omit<
> & {
options?: Omit<
RouteConfigOptions,
- 'access' | 'description' | 'deprecated' | 'discontinued'
+ 'access' | 'description' | 'deprecated' | 'discontinued' | 'security'
>;
/** See {@link RouteConfigOptions['access']} */
access: Exclude['access'], undefined>;
+ /** See {@link RouteConfigOptions['security']} */
+ security?: Exclude['security'], undefined>;
/**
* When enabled, the router will also check for the presence of an `apiVersion`
* query parameter to determine the route version to resolve to:
@@ -337,6 +339,8 @@ export interface AddVersionOpts {
* @public
*/
validate: false | VersionedRouteValidation
| (() => VersionedRouteValidation
); // Provide a way to lazily load validation schemas
+
+ security?: Exclude['security'], undefined>;
}
/**
diff --git a/src/core/server/index.ts b/src/core/server/index.ts
index 5282f2048dd06..f4852bdc97fe3 100644
--- a/src/core/server/index.ts
+++ b/src/core/server/index.ts
@@ -242,7 +242,7 @@ export type {
} from '@kbn/core-http-server';
export type { IExternalUrlPolicy } from '@kbn/core-http-common';
-export { validBodyOutput } from '@kbn/core-http-server';
+export { validBodyOutput, OnPostAuthResultType } from '@kbn/core-http-server';
export type {
HttpResourcesRenderOptions,
@@ -605,3 +605,12 @@ export type {
};
export type { CustomBrandingSetup } from '@kbn/core-custom-branding-server';
+export type {
+ AuthzDisabled,
+ AuthzEnabled,
+ RouteAuthz,
+ RouteSecurity,
+ RouteSecurityGetter,
+ Privilege,
+ PrivilegeSet,
+} from '@kbn/core-http-server';
diff --git a/src/core/server/integration_tests/http/request_representation.test.ts b/src/core/server/integration_tests/http/request_representation.test.ts
index 0688a858799e4..f180a3a49ce0f 100644
--- a/src/core/server/integration_tests/http/request_representation.test.ts
+++ b/src/core/server/integration_tests/http/request_representation.test.ts
@@ -122,10 +122,12 @@ describe('request logging', () => {
xsrfRequired: false,
access: 'internal',
tags: [],
+ security: undefined,
timeout: [Object],
body: undefined
}
- }
+ },
+ authzResult: undefined
}"
`);
});
diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts
index 60644c5c6d21a..8d0237916bb5a 100644
--- a/x-pack/plugins/security/common/constants.ts
+++ b/x-pack/plugins/security/common/constants.ts
@@ -111,3 +111,8 @@ export const SESSION_ROUTE = '/internal/security/session';
* Allowed image file types for uploading an image as avatar
*/
export const IMAGE_FILE_TYPES = ['image/svg+xml', 'image/jpeg', 'image/png', 'image/gif'];
+
+/**
+ * Prefix for API actions.
+ */
+export const API_OPERATION_PREFIX = 'api:';
diff --git a/x-pack/plugins/security/server/authorization/api_authorization.test.ts b/x-pack/plugins/security/server/authorization/api_authorization.test.ts
index 762a9a617498a..e928d73220274 100644
--- a/x-pack/plugins/security/server/authorization/api_authorization.test.ts
+++ b/x-pack/plugins/security/server/authorization/api_authorization.test.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
+import type { RouteSecurity } from '@kbn/core/server';
import {
coreMock,
httpServerMock,
@@ -137,4 +138,279 @@ describe('initAPIAuthorization', () => {
});
expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest);
});
+
+ describe('security config', () => {
+ const testSecurityConfig = (
+ description: string,
+ {
+ security,
+ kibanaPrivilegesResponse,
+ kibanaPrivilegesRequestActions,
+ asserts,
+ }: {
+ security?: RouteSecurity;
+ kibanaPrivilegesResponse?: Array<{ privilege: string; authorized: boolean }>;
+ kibanaPrivilegesRequestActions?: string[];
+ asserts: {
+ forbidden?: boolean;
+ authzResult?: Record;
+ authzDisabled?: boolean;
+ };
+ }
+ ) => {
+ test(description, async () => {
+ const mockHTTPSetup = coreMock.createSetup().http;
+ const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' });
+ initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get());
+
+ const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls;
+
+ const headers = { authorization: 'foo' };
+
+ const mockRequest = httpServerMock.createKibanaRequest({
+ method: 'get',
+ path: '/foo/bar',
+ headers,
+ kibanaRouteOptions: {
+ xsrfRequired: true,
+ access: 'internal',
+ security,
+ },
+ });
+ const mockResponse = httpServerMock.createResponseFactory();
+ const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit();
+
+ const mockCheckPrivileges = jest.fn().mockReturnValue({
+ privileges: {
+ kibana: kibanaPrivilegesResponse,
+ },
+ });
+ mockAuthz.mode.useRbacForRequest.mockReturnValue(true);
+ mockAuthz.checkPrivilegesDynamicallyWithRequest.mockImplementation((request) => {
+ // hapi conceals the actual "request" from us, so we make sure that the headers are passed to
+ // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with
+ expect(request.headers).toMatchObject(headers);
+
+ return mockCheckPrivileges;
+ });
+
+ await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit);
+
+ expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest);
+
+ if (asserts.authzDisabled) {
+ expect(mockResponse.forbidden).not.toHaveBeenCalled();
+ expect(mockPostAuthToolkit.authzResultNext).not.toHaveBeenCalled();
+ expect(mockPostAuthToolkit.next).toHaveBeenCalled();
+ expect(mockCheckPrivileges).not.toHaveBeenCalled();
+
+ return;
+ }
+
+ expect(mockCheckPrivileges).toHaveBeenCalledWith({
+ kibana: kibanaPrivilegesRequestActions!.map((action: string) =>
+ mockAuthz.actions.api.get(action)
+ ),
+ });
+
+ if (asserts.forbidden) {
+ expect(mockResponse.forbidden).toHaveBeenCalled();
+ expect(mockPostAuthToolkit.authzResultNext).not.toHaveBeenCalled();
+ }
+
+ if (asserts.authzResult) {
+ expect(mockResponse.forbidden).not.toHaveBeenCalled();
+ expect(mockPostAuthToolkit.authzResultNext).toHaveBeenCalledTimes(1);
+ expect(mockPostAuthToolkit.authzResultNext).toHaveBeenCalledWith(asserts.authzResult);
+ }
+ });
+ };
+
+ testSecurityConfig(
+ `protected route returns "authzResult" if user has allRequired AND anyRequired privileges requested`,
+ {
+ security: {
+ authz: {
+ requiredPrivileges: [
+ {
+ allRequired: ['privilege1'],
+ anyRequired: ['privilege2', 'privilege3'],
+ },
+ ],
+ },
+ },
+ kibanaPrivilegesResponse: [
+ { privilege: 'api:privilege1', authorized: true },
+ { privilege: 'api:privilege2', authorized: true },
+ { privilege: 'api:privilege3', authorized: false },
+ ],
+ kibanaPrivilegesRequestActions: ['privilege1', 'privilege2', 'privilege3'],
+ asserts: {
+ authzResult: {
+ privilege1: true,
+ privilege2: true,
+ privilege3: false,
+ },
+ },
+ }
+ );
+
+ testSecurityConfig(
+ `protected route returns "authzResult" if user has all required privileges requested as complex config`,
+ {
+ security: {
+ authz: {
+ requiredPrivileges: [
+ {
+ allRequired: ['privilege1', 'privilege2'],
+ },
+ ],
+ },
+ },
+ kibanaPrivilegesResponse: [
+ { privilege: 'api:privilege1', authorized: true },
+ { privilege: 'api:privilege2', authorized: true },
+ ],
+ kibanaPrivilegesRequestActions: ['privilege1', 'privilege2'],
+ asserts: {
+ authzResult: {
+ privilege1: true,
+ privilege2: true,
+ },
+ },
+ }
+ );
+
+ testSecurityConfig(
+ `protected route returns "authzResult" if user has at least one of anyRequired privileges requested`,
+ {
+ security: {
+ authz: {
+ requiredPrivileges: [
+ {
+ anyRequired: ['privilege1', 'privilege2', 'privilege3'],
+ },
+ ],
+ },
+ },
+ kibanaPrivilegesResponse: [
+ { privilege: 'api:privilege1', authorized: false },
+ { privilege: 'api:privilege2', authorized: true },
+ { privilege: 'api:privilege3', authorized: false },
+ ],
+ kibanaPrivilegesRequestActions: ['privilege1', 'privilege2', 'privilege3'],
+ asserts: {
+ authzResult: {
+ privilege1: false,
+ privilege2: true,
+ privilege3: false,
+ },
+ },
+ }
+ );
+
+ testSecurityConfig(
+ `protected route returns "authzResult" if user has all required privileges requested as simple config`,
+ {
+ security: {
+ authz: {
+ requiredPrivileges: ['privilege1', 'privilege2'],
+ },
+ },
+ kibanaPrivilegesResponse: [
+ { privilege: 'api:privilege1', authorized: true },
+ { privilege: 'api:privilege2', authorized: true },
+ ],
+ kibanaPrivilegesRequestActions: ['privilege1', 'privilege2'],
+ asserts: {
+ authzResult: {
+ privilege1: true,
+ privilege2: true,
+ },
+ },
+ }
+ );
+
+ testSecurityConfig(
+ `protected route returns forbidden if user has allRequired AND NONE of anyRequired privileges requested`,
+ {
+ security: {
+ authz: {
+ requiredPrivileges: [
+ {
+ allRequired: ['privilege1'],
+ anyRequired: ['privilege2', 'privilege3'],
+ },
+ ],
+ },
+ },
+ kibanaPrivilegesResponse: [
+ { privilege: 'api:privilege1', authorized: true },
+ { privilege: 'api:privilege2', authorized: false },
+ { privilege: 'api:privilege3', authorized: false },
+ ],
+ kibanaPrivilegesRequestActions: ['privilege1', 'privilege2', 'privilege3'],
+ asserts: {
+ forbidden: true,
+ },
+ }
+ );
+
+ testSecurityConfig(
+ `protected route returns forbidden if user doesn't have at least one from allRequired privileges requested`,
+ {
+ security: {
+ authz: {
+ requiredPrivileges: [
+ {
+ allRequired: ['privilege1', 'privilege2'],
+ anyRequired: ['privilege3', 'privilege4'],
+ },
+ ],
+ },
+ },
+ kibanaPrivilegesResponse: [
+ { privilege: 'api:privilege1', authorized: true },
+ { privilege: 'api:privilege2', authorized: false },
+ { privilege: 'api:privilege3', authorized: false },
+ { privilege: 'api:privilege4', authorized: true },
+ ],
+ kibanaPrivilegesRequestActions: ['privilege1', 'privilege2', 'privilege3', 'privilege4'],
+ asserts: {
+ forbidden: true,
+ },
+ }
+ );
+
+ testSecurityConfig(
+ `protected route returns forbidden if user doesn't have at least one from required privileges requested as simple config`,
+ {
+ security: {
+ authz: {
+ requiredPrivileges: ['privilege1', 'privilege2'],
+ },
+ },
+ kibanaPrivilegesResponse: [
+ { privilege: 'api:privilege1', authorized: true },
+ { privilege: 'api:privilege2', authorized: false },
+ ],
+ kibanaPrivilegesRequestActions: ['privilege1', 'privilege2'],
+ asserts: {
+ forbidden: true,
+ },
+ }
+ );
+
+ testSecurityConfig(`route returns next if route has authz disabled`, {
+ security: {
+ authz: {
+ enabled: false,
+ reason: 'authz is disabled',
+ },
+ },
+ asserts: {
+ authzDisabled: true,
+ },
+ });
+ });
});
diff --git a/x-pack/plugins/security/server/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts
index 6956a91d81265..ba38d9ca0aa20 100644
--- a/x-pack/plugins/security/server/authorization/api_authorization.ts
+++ b/x-pack/plugins/security/server/authorization/api_authorization.ts
@@ -5,8 +5,23 @@
* 2.0.
*/
-import type { HttpServiceSetup, Logger } from '@kbn/core/server';
+import type {
+ AuthzDisabled,
+ AuthzEnabled,
+ HttpServiceSetup,
+ Logger,
+ Privilege,
+ PrivilegeSet,
+ RouteAuthz,
+} from '@kbn/core/server';
import type { AuthorizationServiceSetup } from '@kbn/security-plugin-types-server';
+import type { RecursiveReadonly } from '@kbn/utility-types';
+
+import { API_OPERATION_PREFIX } from '../../common/constants';
+
+const isAuthzDisabled = (authz?: RecursiveReadonly): authz is AuthzDisabled => {
+ return (authz as AuthzDisabled)?.enabled === false;
+};
export function initAPIAuthorization(
http: HttpServiceSetup,
@@ -19,6 +34,78 @@ export function initAPIAuthorization(
return toolkit.next();
}
+ const security = request.route.options.security;
+
+ if (security) {
+ if (isAuthzDisabled(security.authz)) {
+ logger.warn(
+ `Route authz is disabled for ${request.url.pathname}${request.url.search}": ${security.authz.reason}`
+ );
+
+ return toolkit.next();
+ }
+
+ const authz = security.authz as AuthzEnabled;
+
+ const requestedPrivileges = authz.requiredPrivileges.flatMap((privilegeEntry) => {
+ if (typeof privilegeEntry === 'object') {
+ return [...(privilegeEntry.allRequired ?? []), ...(privilegeEntry.anyRequired ?? [])];
+ }
+
+ return privilegeEntry;
+ });
+
+ const apiActions = requestedPrivileges.map((permission) => actions.api.get(permission));
+ const checkPrivileges = checkPrivilegesDynamicallyWithRequest(request);
+ const checkPrivilegesResponse = await checkPrivileges({ kibana: apiActions });
+
+ const privilegeToApiOperation = (privilege: string) =>
+ privilege.replace(API_OPERATION_PREFIX, '');
+ const kibanaPrivileges: Record = {};
+
+ for (const kbPrivilege of checkPrivilegesResponse.privileges.kibana) {
+ kibanaPrivileges[privilegeToApiOperation(kbPrivilege.privilege)] = kbPrivilege.authorized;
+ }
+
+ const hasRequestedPrivilege = (kbPrivilege: Privilege | PrivilegeSet) => {
+ if (typeof kbPrivilege === 'object') {
+ const allRequired = kbPrivilege.allRequired ?? [];
+ const anyRequired = kbPrivilege.anyRequired ?? [];
+
+ return (
+ allRequired.every((privilege: string) => kibanaPrivileges[privilege]) &&
+ (!anyRequired.length ||
+ anyRequired.some((privilege: string) => kibanaPrivileges[privilege]))
+ );
+ }
+
+ return kibanaPrivileges[kbPrivilege];
+ };
+
+ for (const requiredPrivilege of authz.requiredPrivileges) {
+ if (!hasRequestedPrivilege(requiredPrivilege)) {
+ const missingPrivileges = Object.keys(kibanaPrivileges).filter(
+ (key) => !kibanaPrivileges[key]
+ );
+ logger.warn(
+ `User not authorized for "${request.url.pathname}${
+ request.url.search
+ }", responding with 403: missing privileges: ${missingPrivileges.join(', ')}`
+ );
+
+ return response.forbidden({
+ body: {
+ message: `User not authorized for ${request.url.pathname}${
+ request.url.search
+ }, missing privileges: ${missingPrivileges.join(', ')}`,
+ },
+ });
+ }
+ }
+
+ return toolkit.authzResultNext(kibanaPrivileges);
+ }
+
const tags = request.route.options.tags;
const tagPrefix = 'access:';
const actionTags = tags.filter((tag) => tag.startsWith(tagPrefix));
diff --git a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts
index 27205a30be785..a1b71a9c4f04f 100644
--- a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts
@@ -187,7 +187,7 @@ describe('ProductFeaturesService', () => {
url: { pathname: '', search: '' },
} as unknown as KibanaRequest);
const res = { notFound: jest.fn() } as unknown as LifecycleResponseFactory;
- const toolkit = { next: jest.fn() };
+ const toolkit = httpServiceMock.createOnPostAuthToolkit();
beforeEach(() => {
jest.clearAllMocks();