diff --git a/packages/core/http/core-http-router-server-internal/src/request.test.ts b/packages/core/http/core-http-router-server-internal/src/request.test.ts index ec1465ac049b7..1f607a58961ab 100644 --- a/packages/core/http/core-http-router-server-internal/src/request.test.ts +++ b/packages/core/http/core-http-router-server-internal/src/request.test.ts @@ -13,7 +13,7 @@ jest.mock('uuid', () => ({ import { RouteOptions } from '@hapi/hapi'; import { hapiMocks } from '@kbn/hapi-mocks'; -import type { FakeRawRequest } from '@kbn/core-http-server'; +import type { FakeRawRequest, RouteSecurity } from '@kbn/core-http-server'; import { CoreKibanaRequest } from './request'; import { schema } from '@kbn/config-schema'; import { @@ -352,6 +352,153 @@ describe('CoreKibanaRequest', () => { }); }); + describe('route.options.security property', () => { + it('handles required authc: undefined', () => { + const request = hapiMocks.createRequest({ + route: { + settings: { + app: { + security: { authc: undefined }, + }, + }, + }, + }); + const kibanaRequest = CoreKibanaRequest.from(request); + + expect(kibanaRequest.route.options.authRequired).toBe(true); + }); + + it('handles required authc: { enabled: undefined }', () => { + const request = hapiMocks.createRequest({ + route: { + settings: { + app: { + security: { authc: { enabled: undefined } }, + }, + }, + }, + }); + const kibanaRequest = CoreKibanaRequest.from(request); + + expect(kibanaRequest.route.options.authRequired).toBe(true); + }); + + it('handles required authc: { enabled: true }', () => { + const request = hapiMocks.createRequest({ + route: { + settings: { + app: { + security: { authc: { enabled: true } }, + }, + }, + }, + }); + const kibanaRequest = CoreKibanaRequest.from(request); + + expect(kibanaRequest.route.options.authRequired).toBe(true); + }); + it('handles required authc: { enabled: false }', () => { + const request = hapiMocks.createRequest({ + route: { + settings: { + app: { + security: { authc: { enabled: false } }, + }, + }, + }, + }); + const kibanaRequest = CoreKibanaRequest.from(request); + + expect(kibanaRequest.route.options.authRequired).toBe(false); + }); + + it(`handles required authc: { enabled: 'optional' }`, () => { + const request = hapiMocks.createRequest({ + route: { + settings: { + app: { + security: { authc: { enabled: 'optional' } }, + }, + }, + }, + }); + const kibanaRequest = CoreKibanaRequest.from(request); + + expect(kibanaRequest.route.options.authRequired).toBe('optional'); + }); + + it('handles required authz simple config', () => { + const security: RouteSecurity = { + authz: { + requiredPrivileges: ['privilege1'], + }, + }; + const request = hapiMocks.createRequest({ + route: { + settings: { + app: { + security, + }, + }, + }, + }); + const kibanaRequest = CoreKibanaRequest.from(request); + + expect(kibanaRequest.route.options.security).toEqual(security); + }); + + it('handles required authz complex config', () => { + const security: RouteSecurity = { + authz: { + requiredPrivileges: [ + { + allRequired: ['privilege1'], + anyRequired: ['privilege2', 'privilege3'], + }, + ], + }, + }; + const request = hapiMocks.createRequest({ + route: { + settings: { + app: { + security, + }, + }, + }, + }); + const kibanaRequest = CoreKibanaRequest.from(request); + + expect(kibanaRequest.route.options.security).toEqual(security); + }); + + it('handles required authz config for the route with RouteSecurityGetter', () => { + const security: RouteSecurity = { + authz: { + requiredPrivileges: [ + { + allRequired: ['privilege1'], + anyRequired: ['privilege2', 'privilege3'], + }, + ], + }, + }; + const request = hapiMocks.createRequest({ + route: { + settings: { + app: { + // security is a getter function only for the versioned routes + security: () => security, + }, + }, + }, + }); + const kibanaRequest = CoreKibanaRequest.from(request); + + expect(kibanaRequest.route.options.security).toEqual(security); + }); + }); + describe('RouteSchema type inferring', () => { it('should work with config-schema', () => { const body = Buffer.from('body!'); diff --git a/packages/core/http/core-http-router-server-internal/src/request.ts b/packages/core/http/core-http-router-server-internal/src/request.ts index b7b23186def88..286b900fc24f5 100644 --- a/packages/core/http/core-http-router-server-internal/src/request.ts +++ b/packages/core/http/core-http-router-server-internal/src/request.ts @@ -31,6 +31,8 @@ import { RawRequest, FakeRawRequest, HttpProtocol, + RouteSecurityGetter, + RouteSecurity, } from '@kbn/core-http-server'; import { ELASTIC_INTERNAL_ORIGIN_QUERY_PARAM, @@ -46,6 +48,12 @@ patchRequest(); const requestSymbol = Symbol('request'); +const isRouteSecurityGetter = ( + security?: RouteSecurityGetter | RecursiveReadonly +): security is RouteSecurityGetter => { + return typeof security === 'function'; +}; + /** * Core internal implementation of {@link KibanaRequest} * @internal @@ -137,6 +145,8 @@ export class CoreKibanaRequest< public readonly httpVersion: string; /** {@inheritDoc KibanaRequest.protocol} */ public readonly protocol: HttpProtocol; + /** {@inheritDoc KibanaRequest.authzResult} */ + public readonly authzResult?: Record; /** @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();