[8.x] [Authz] Added support for security route configuration option (#191973) (#194667)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Authz] Added support for security route configuration option
(#191973)](https://github.com/elastic/kibana/pull/191973)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Elena
Shostak","email":"165678770+elena-shostak@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-09-30T11:13:26Z","message":"[Authz]
Added support for security route configuration option (#191973)\n\n##
Summary\r\n\r\nExtended `KibanaRouteOptions` to include security
configuration at the\r\nroute definition level.\r\n\r\n## Security
Config\r\nTo facilitate iterative development security config is marked
as\r\noptional for now.\r\n\r\n- `authz` supports both simple
configuration (e.g., single privilege\r\nrequirements) and more complex
configurations that involve anyRequired\r\nand allRequired privilege
sets.\r\n- `authc` property has been added and is expected to replace
the\r\nexisting `authRequired` option. This transition will be part of
an\r\nupcoming deprecation process in scope
of\r\nhttps://github.com/elastic/kibana/issues/191711\r\n- For versioned
routes, the `authc` and `authz` configurations can be\r\napplied
independently for each version, enabling version-specific\r\nsecurity
configuration. If none provided for the specific version it\r\nwill fall
back to the route root security option.\r\n- Validation logic has been
added that ensures only supported\r\nconfigurations are specified.\r\n-
Existing `registerOnPostAuth` hook has been modified to
incorporate\r\nchecks based on the new `authz` property in the security
configuration.\r\n- Comprehensive documentation will be added in the
separate PR before\r\nsunsetting new security configuration and
deprecating old one.\r\n\r\n## How to Test\r\nYou can modify any
existing route or use the example routes below\r\n###
Routes\r\n\r\n<details>\r\n<summary><b>Route
1:\r\n/api/security/authz_examples/authz_disabled</b></summary>\r\n\r\n```javascript\r\nrouter.get(\r\n
{\r\n path: '/api/security/authz_examples/authz_disabled',\r\n security:
{\r\n authz: {\r\n enabled: false,\r\n reason: 'This route is opted out
from authorization',\r\n },\r\n },\r\n validate: false,\r\n },\r\n
createLicensedRouteHandler(async (context, request, response) => {\r\n
try {\r\n return response.ok({\r\n body: {\r\n message: 'This route is
opted out from authorization',\r\n },\r\n });\r\n } catch (error) {\r\n
return response.customError(wrapIntoCustomErrorResponse(error));\r\n
}\r\n
})\r\n);\r\n```\r\n</details>\r\n\r\n<details>\r\n<summary><b>Route
2:\r\n/api/security/authz_examples/simple_privileges_1</b></summary>\r\n\r\n```javascript\r\nrouter.get(\r\n
{\r\n path: '/api/security/authz_examples/simple_privileges_1',\r\n
security: {\r\n authz: {\r\n requiredPrivileges: ['manageSpaces',
'taskManager'],\r\n },\r\n },\r\n validate: false,\r\n },\r\n
createLicensedRouteHandler(async (context, request, response) => {\r\n
try {\r\n return response.ok({\r\n body: {\r\n authzResult:
request.authzResult,\r\n },\r\n });\r\n } catch (error) {\r\n return
response.customError(wrapIntoCustomErrorResponse(error));\r\n }\r\n
})\r\n);\r\n```\r\n</details>\r\n\r\n<details>\r\n<summary><b>Route
3:\r\n/api/security/authz_examples/simple_privileges_2</b></summary>\r\n\r\n```javascript\r\nrouter.get(\r\n
{\r\n path: '/api/security/authz_examples/simple_privileges_2',\r\n
security: {\r\n authz: {\r\n requiredPrivileges: [\r\n
'manageSpaces',\r\n {\r\n anyRequired: ['taskManager', 'features'],\r\n
},\r\n ],\r\n },\r\n },\r\n validate: false,\r\n },\r\n
createLicensedRouteHandler(async (context, request, response) => {\r\n
try {\r\n return response.ok({\r\n body: {\r\n authzResult:
request.authzResult,\r\n },\r\n });\r\n } catch (error) {\r\n return
response.customError(wrapIntoCustomErrorResponse(error));\r\n }\r\n
})\r\n);\r\n```\r\n</details>\r\n\r\n<details>\r\n<summary><b>Versioned
Route:\r\n/internal/security/authz_versioned_examples/simple_privileges_1</b></summary>\r\n\r\n```javascript\r\nrouter.versioned\r\n
.get({\r\n path:
'/internal/security/authz_versioned_examples/simple_privileges_1',\r\n
access: 'internal',\r\n enableQueryVersion: true,\r\n })\r\n
.addVersion(\r\n {\r\n version: '1',\r\n validate: false,\r\n security:
{\r\n authz: {\r\n requiredPrivileges: ['manageSpaces',
'taskManager'],\r\n },\r\n authc: {\r\n enabled: 'optional',\r\n },\r\n
},\r\n },\r\n (context, request, response) => {\r\n return
response.ok({\r\n body: {\r\n authzResult: request.authzResult,\r\n
version: '1',\r\n },\r\n });\r\n }\r\n )\r\n .addVersion(\r\n {\r\n
version: '2',\r\n validate: false,\r\n security: {\r\n authz: {\r\n
requiredPrivileges: ['manageSpaces'],\r\n },\r\n authc: {\r\n enabled:
true,\r\n },\r\n },\r\n },\r\n (context, request, response) => {\r\n
return response.ok({\r\n body: {\r\n authzResult:
request.authzResult,\r\n version: '2',\r\n },\r\n });\r\n }\r\n
);\r\n```\r\n</details>\r\n\r\n\r\n\r\n### Checklist\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n\r\n__Closes:
https://github.com/elastic/kibana/issues/191710__\r\n__Related:
https://github.com/elastic/kibana/issues/191712,\r\nhttps://github.com/elastic/kibana/issues/191713__\r\n\r\n###
Release Note\r\nExtended `KibanaRouteOptions` to include security
configuration at the\r\nroute definition
level.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"9a7e9124cf86d8a7a7ce25732bf11163ec7df52d","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:enhancement","chore","Team:Security","Feature:Security/Authentication","Feature:Security/Authorization","backport:skip","v9.0.0"],"number":191973,"url":"https://github.com/elastic/kibana/pull/191973","mergeCommit":{"message":"[Authz]
Added support for security route configuration option (#191973)\n\n##
Summary\r\n\r\nExtended `KibanaRouteOptions` to include security
configuration at the\r\nroute definition level.\r\n\r\n## Security
Config\r\nTo facilitate iterative development security config is marked
as\r\noptional for now.\r\n\r\n- `authz` supports both simple
configuration (e.g., single privilege\r\nrequirements) and more complex
configurations that involve anyRequired\r\nand allRequired privilege
sets.\r\n- `authc` property has been added and is expected to replace
the\r\nexisting `authRequired` option. This transition will be part of
an\r\nupcoming deprecation process in scope
of\r\nhttps://github.com/elastic/kibana/issues/191711\r\n- For versioned
routes, the `authc` and `authz` configurations can be\r\napplied
independently for each version, enabling version-specific\r\nsecurity
configuration. If none provided for the specific version it\r\nwill fall
back to the route root security option.\r\n- Validation logic has been
added that ensures only supported\r\nconfigurations are specified.\r\n-
Existing `registerOnPostAuth` hook has been modified to
incorporate\r\nchecks based on the new `authz` property in the security
configuration.\r\n- Comprehensive documentation will be added in the
separate PR before\r\nsunsetting new security configuration and
deprecating old one.\r\n\r\n## How to Test\r\nYou can modify any
existing route or use the example routes below\r\n###
Routes\r\n\r\n<details>\r\n<summary><b>Route
1:\r\n/api/security/authz_examples/authz_disabled</b></summary>\r\n\r\n```javascript\r\nrouter.get(\r\n
{\r\n path: '/api/security/authz_examples/authz_disabled',\r\n security:
{\r\n authz: {\r\n enabled: false,\r\n reason: 'This route is opted out
from authorization',\r\n },\r\n },\r\n validate: false,\r\n },\r\n
createLicensedRouteHandler(async (context, request, response) => {\r\n
try {\r\n return response.ok({\r\n body: {\r\n message: 'This route is
opted out from authorization',\r\n },\r\n });\r\n } catch (error) {\r\n
return response.customError(wrapIntoCustomErrorResponse(error));\r\n
}\r\n
})\r\n);\r\n```\r\n</details>\r\n\r\n<details>\r\n<summary><b>Route
2:\r\n/api/security/authz_examples/simple_privileges_1</b></summary>\r\n\r\n```javascript\r\nrouter.get(\r\n
{\r\n path: '/api/security/authz_examples/simple_privileges_1',\r\n
security: {\r\n authz: {\r\n requiredPrivileges: ['manageSpaces',
'taskManager'],\r\n },\r\n },\r\n validate: false,\r\n },\r\n
createLicensedRouteHandler(async (context, request, response) => {\r\n
try {\r\n return response.ok({\r\n body: {\r\n authzResult:
request.authzResult,\r\n },\r\n });\r\n } catch (error) {\r\n return
response.customError(wrapIntoCustomErrorResponse(error));\r\n }\r\n
})\r\n);\r\n```\r\n</details>\r\n\r\n<details>\r\n<summary><b>Route
3:\r\n/api/security/authz_examples/simple_privileges_2</b></summary>\r\n\r\n```javascript\r\nrouter.get(\r\n
{\r\n path: '/api/security/authz_examples/simple_privileges_2',\r\n
security: {\r\n authz: {\r\n requiredPrivileges: [\r\n
'manageSpaces',\r\n {\r\n anyRequired: ['taskManager', 'features'],\r\n
},\r\n ],\r\n },\r\n },\r\n validate: false,\r\n },\r\n
createLicensedRouteHandler(async (context, request, response) => {\r\n
try {\r\n return response.ok({\r\n body: {\r\n authzResult:
request.authzResult,\r\n },\r\n });\r\n } catch (error) {\r\n return
response.customError(wrapIntoCustomErrorResponse(error));\r\n }\r\n
})\r\n);\r\n```\r\n</details>\r\n\r\n<details>\r\n<summary><b>Versioned
Route:\r\n/internal/security/authz_versioned_examples/simple_privileges_1</b></summary>\r\n\r\n```javascript\r\nrouter.versioned\r\n
.get({\r\n path:
'/internal/security/authz_versioned_examples/simple_privileges_1',\r\n
access: 'internal',\r\n enableQueryVersion: true,\r\n })\r\n
.addVersion(\r\n {\r\n version: '1',\r\n validate: false,\r\n security:
{\r\n authz: {\r\n requiredPrivileges: ['manageSpaces',
'taskManager'],\r\n },\r\n authc: {\r\n enabled: 'optional',\r\n },\r\n
},\r\n },\r\n (context, request, response) => {\r\n return
response.ok({\r\n body: {\r\n authzResult: request.authzResult,\r\n
version: '1',\r\n },\r\n });\r\n }\r\n )\r\n .addVersion(\r\n {\r\n
version: '2',\r\n validate: false,\r\n security: {\r\n authz: {\r\n
requiredPrivileges: ['manageSpaces'],\r\n },\r\n authc: {\r\n enabled:
true,\r\n },\r\n },\r\n },\r\n (context, request, response) => {\r\n
return response.ok({\r\n body: {\r\n authzResult:
request.authzResult,\r\n version: '2',\r\n },\r\n });\r\n }\r\n
);\r\n```\r\n</details>\r\n\r\n\r\n\r\n### Checklist\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n\r\n__Closes:
https://github.com/elastic/kibana/issues/191710__\r\n__Related:
https://github.com/elastic/kibana/issues/191712,\r\nhttps://github.com/elastic/kibana/issues/191713__\r\n\r\n###
Release Note\r\nExtended `KibanaRouteOptions` to include security
configuration at the\r\nroute definition
level.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"9a7e9124cf86d8a7a7ce25732bf11163ec7df52d"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/191973","number":191973,"mergeCommit":{"message":"[Authz]
Added support for security route configuration option (#191973)\n\n##
Summary\r\n\r\nExtended `KibanaRouteOptions` to include security
configuration at the\r\nroute definition level.\r\n\r\n## Security
Config\r\nTo facilitate iterative development security config is marked
as\r\noptional for now.\r\n\r\n- `authz` supports both simple
configuration (e.g., single privilege\r\nrequirements) and more complex
configurations that involve anyRequired\r\nand allRequired privilege
sets.\r\n- `authc` property has been added and is expected to replace
the\r\nexisting `authRequired` option. This transition will be part of
an\r\nupcoming deprecation process in scope
of\r\nhttps://github.com/elastic/kibana/issues/191711\r\n- For versioned
routes, the `authc` and `authz` configurations can be\r\napplied
independently for each version, enabling version-specific\r\nsecurity
configuration. If none provided for the specific version it\r\nwill fall
back to the route root security option.\r\n- Validation logic has been
added that ensures only supported\r\nconfigurations are specified.\r\n-
Existing `registerOnPostAuth` hook has been modified to
incorporate\r\nchecks based on the new `authz` property in the security
configuration.\r\n- Comprehensive documentation will be added in the
separate PR before\r\nsunsetting new security configuration and
deprecating old one.\r\n\r\n## How to Test\r\nYou can modify any
existing route or use the example routes below\r\n###
Routes\r\n\r\n<details>\r\n<summary><b>Route
1:\r\n/api/security/authz_examples/authz_disabled</b></summary>\r\n\r\n```javascript\r\nrouter.get(\r\n
{\r\n path: '/api/security/authz_examples/authz_disabled',\r\n security:
{\r\n authz: {\r\n enabled: false,\r\n reason: 'This route is opted out
from authorization',\r\n },\r\n },\r\n validate: false,\r\n },\r\n
createLicensedRouteHandler(async (context, request, response) => {\r\n
try {\r\n return response.ok({\r\n body: {\r\n message: 'This route is
opted out from authorization',\r\n },\r\n });\r\n } catch (error) {\r\n
return response.customError(wrapIntoCustomErrorResponse(error));\r\n
}\r\n
})\r\n);\r\n```\r\n</details>\r\n\r\n<details>\r\n<summary><b>Route
2:\r\n/api/security/authz_examples/simple_privileges_1</b></summary>\r\n\r\n```javascript\r\nrouter.get(\r\n
{\r\n path: '/api/security/authz_examples/simple_privileges_1',\r\n
security: {\r\n authz: {\r\n requiredPrivileges: ['manageSpaces',
'taskManager'],\r\n },\r\n },\r\n validate: false,\r\n },\r\n
createLicensedRouteHandler(async (context, request, response) => {\r\n
try {\r\n return response.ok({\r\n body: {\r\n authzResult:
request.authzResult,\r\n },\r\n });\r\n } catch (error) {\r\n return
response.customError(wrapIntoCustomErrorResponse(error));\r\n }\r\n
})\r\n);\r\n```\r\n</details>\r\n\r\n<details>\r\n<summary><b>Route
3:\r\n/api/security/authz_examples/simple_privileges_2</b></summary>\r\n\r\n```javascript\r\nrouter.get(\r\n
{\r\n path: '/api/security/authz_examples/simple_privileges_2',\r\n
security: {\r\n authz: {\r\n requiredPrivileges: [\r\n
'manageSpaces',\r\n {\r\n anyRequired: ['taskManager', 'features'],\r\n
},\r\n ],\r\n },\r\n },\r\n validate: false,\r\n },\r\n
createLicensedRouteHandler(async (context, request, response) => {\r\n
try {\r\n return response.ok({\r\n body: {\r\n authzResult:
request.authzResult,\r\n },\r\n });\r\n } catch (error) {\r\n return
response.customError(wrapIntoCustomErrorResponse(error));\r\n }\r\n
})\r\n);\r\n```\r\n</details>\r\n\r\n<details>\r\n<summary><b>Versioned
Route:\r\n/internal/security/authz_versioned_examples/simple_privileges_1</b></summary>\r\n\r\n```javascript\r\nrouter.versioned\r\n
.get({\r\n path:
'/internal/security/authz_versioned_examples/simple_privileges_1',\r\n
access: 'internal',\r\n enableQueryVersion: true,\r\n })\r\n
.addVersion(\r\n {\r\n version: '1',\r\n validate: false,\r\n security:
{\r\n authz: {\r\n requiredPrivileges: ['manageSpaces',
'taskManager'],\r\n },\r\n authc: {\r\n enabled: 'optional',\r\n },\r\n
},\r\n },\r\n (context, request, response) => {\r\n return
response.ok({\r\n body: {\r\n authzResult: request.authzResult,\r\n
version: '1',\r\n },\r\n });\r\n }\r\n )\r\n .addVersion(\r\n {\r\n
version: '2',\r\n validate: false,\r\n security: {\r\n authz: {\r\n
requiredPrivileges: ['manageSpaces'],\r\n },\r\n authc: {\r\n enabled:
true,\r\n },\r\n },\r\n },\r\n (context, request, response) => {\r\n
return response.ok({\r\n body: {\r\n authzResult:
request.authzResult,\r\n version: '2',\r\n },\r\n });\r\n }\r\n
);\r\n```\r\n</details>\r\n\r\n\r\n\r\n### Checklist\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n\r\n__Closes:
https://github.com/elastic/kibana/issues/191710__\r\n__Related:
https://github.com/elastic/kibana/issues/191712,\r\nhttps://github.com/elastic/kibana/issues/191713__\r\n\r\n###
Release Note\r\nExtended `KibanaRouteOptions` to include security
configuration at the\r\nroute definition
level.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"9a7e9124cf86d8a7a7ce25732bf11163ec7df52d"}}]}]
BACKPORT-->
This commit is contained in:
Elena Shostak 2024-10-02 13:46:17 +02:00 committed by GitHub
parent 1f4ffc4b3f
commit 0b748f0b36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1540 additions and 38 deletions

View file

@ -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!');

View file

@ -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<RouteSecurity>
): 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<string, boolean>;
/** @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,

View file

@ -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<P, Q, B, M extends RouteMethod> = Omit<
RouteConfig<P, Q, B, M>,
'security'
> & {
security?: RouteSecurityGetter | RouteSecurity;
};

View file

@ -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({}));

View file

@ -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<P, Q, B>(
route: RouteConfig<P, Q, B, typeof routeMethod>,
route: InternalRouteConfig<P, Q, B, typeof routeMethod>,
routeMethod: RouteMethod
) {
// The type doesn't allow `validate` to be undefined, but it can still
@ -93,7 +98,7 @@ function routeSchemasFromRouteConfig<P, Q, B>(
*/
function validOptions(
method: RouteMethod,
routeConfig: RouteConfig<unknown, unknown, unknown, typeof method>
routeConfig: InternalRouteConfig<unknown, unknown, unknown, typeof method>
) {
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<P, Q, B, M extends RouteMethod> = Omit<
RouteConfig<P, Q, B, M>,
'security'
> & {
security?: RouteSecurityGetter;
};
/** @internal */
export type InternalRegistrar<M extends Method, C extends RequestHandlerContextBase> = <P, Q, B>(
route: RouteConfig<P, Q, B, M>,
route: InternalRouteConfig<P, Q, B, M>,
handler: RequestHandler<P, Q, B, C, M>,
internalOpts?: InternalRegistrarOptions
) => ReturnType<RouteRegistrar<M, C>>;
@ -186,7 +198,7 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
const buildMethod =
<Method extends RouteMethod>(method: Method) =>
<P, Q, B>(
route: RouteConfig<P, Q, B, Method>,
route: InternalRouteConfig<P, Q, B, Method>,
handler: RequestHandler<P, Q, B, Context, Method>,
internalOptions: { isVersioned: boolean } = { isVersioned: false }
) => {
@ -204,6 +216,10 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
method,
path: getRouteFullPath(this.routerPath, route.path),
options: validOptions(method, route),
// For the versioned route security is validated in the versioned router
security: internalOptions.isVersioned
? route.security
: validRouteSecurity(route.security as DeepPartial<RouteSecurity>, route.options),
/** Below is added for introspection */
validationSchemas: route.validate,
isVersioned: internalOptions.isVersioned,
@ -271,7 +287,12 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
>;
const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit);
try {
kibanaRequest = CoreKibanaRequest.from(request, routeSchemas);
kibanaRequest = CoreKibanaRequest.from(request, routeSchemas) as KibanaRequest<
P,
Q,
B,
typeof request.method extends RouteMethod ? typeof request.method : any
>;
} catch (error) {
this.logError('400 Bad Request', 400, { request, error });
return hapiResponseAdapter.toBadRequest(error.message);

View file

@ -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"`
);
});
});

View file

@ -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<RouteSecurity>,
options?: DeepPartial<RouteConfigOptions<any>>
) => {
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);
};

View file

@ -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<P, Q, B>(validator: RouteValidator<P, Q, B>) {
// Integration tested in ./routes.test.ts
export function prepareRouteConfigValidation<P, Q, B>(
config: RouteConfig<P, Q, B, RouteMethod>
): RouteConfig<P, Q, B, RouteMethod> {
config: InternalRouteConfig<P, Q, B, RouteMethod>
): InternalRouteConfig<P, Q, B, RouteMethod> {
// 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') {

View file

@ -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]"
`);
});
});

View file

@ -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<unknown, unknown, unknown>;
@ -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(', ') + ']' : '<none>';
}
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<KibanaRequest>;
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<any, any, any, any>): 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;
};
}

View file

@ -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<KibanaRequest>
): request is Mutable<KibanaRequestWithQueryVersion> {
@ -63,13 +68,13 @@ export function removeQueryVersion(request: Mutable<KibanaRequestWithQueryVersio
delete request.query[ELASTIC_HTTP_VERSION_QUERY_PARAM];
}
function readQueryVersion(request: KibanaRequest): undefined | ApiVersion {
function readQueryVersion(request: RequestLike): undefined | ApiVersion {
const version = get(request.query, ELASTIC_HTTP_VERSION_QUERY_PARAM);
if (typeof version === 'string') return version;
}
/** Reading from header takes precedence over query param */
export function readVersion(
request: KibanaRequest,
request: RequestLike,
isQueryVersionEnabled?: boolean
): undefined | ApiVersion {
const versions = request.headers?.[ELASTIC_HTTP_VERSION_HEADER];

View file

@ -57,6 +57,99 @@ describe('prepareVersionedRouteValidation', () => {
},
});
});
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', () => {

View file

@ -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<unknown, unknown
export function prepareVersionedRouteValidation(
options: AddVersionOpts<unknown, unknown, unknown>
): AddVersionOpts<unknown, unknown, unknown> {
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,
};
}

View file

@ -699,6 +699,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(

View file

@ -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<string, boolean>) => {
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}.`
);

View file

@ -40,6 +40,7 @@ const createToolkit = (): ToolkitMock => {
render: jest.fn(),
next: jest.fn(),
rewriteUrl: jest.fn(),
authzResultNext: jest.fn(),
};
};

View file

@ -34,6 +34,7 @@ const createToolkitMock = (): ToolkitMock => {
render: jest.fn(),
next: jest.fn(),
rewriteUrl: jest.fn(),
authzResultNext: jest.fn(),
};
};

View file

@ -276,6 +276,7 @@ const createOnPreAuthToolkitMock = (): jest.Mocked<OnPreAuthToolkit> => ({
const createOnPostAuthToolkitMock = (): jest.Mocked<OnPostAuthToolkit> => ({
next: jest.fn(),
authzResultNext: jest.fn(),
});
const createOnPreRoutingToolkitMock = (): jest.Mocked<OnPreRoutingToolkit> => ({

View file

@ -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,

View file

@ -25,6 +25,7 @@ export type {
OnPostAuthNextResult,
OnPostAuthToolkit,
OnPostAuthResult,
OnPostAuthAuthzResult,
} from './on_post_auth';
export { OnPostAuthResultType } from './on_post_auth';

View file

@ -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<string, boolean>;
}
/**
* @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<string, boolean>) => OnPostAuthAuthzResult;
}
/**

View file

@ -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,

View file

@ -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<string, boolean>;
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<string, boolean>;
/**
* An internal request has access to internal routes.
* @note See the {@link KibanaRequestRouteOptions#access} route option.

View file

@ -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<Privilege | PrivilegeSet>;
/**
* 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<Method extends RouteMethod> {
* @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<P, Q, B, Method extends RouteMethod> {
*/
validate: RouteValidator<P, Q, B> | (() => RouteValidator<P, Q, B>) | false;
/**
* Defines the security requirements for a route, including authorization and authentication.
*/
security?: RouteSecurity;
/**
* Additional route options {@link RouteConfigOptions}.
*/

View file

@ -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<RouteMethod>;
security?: InternalRouteSecurity;
/**
* @note if providing a function to lazily load your validation schemas assume
* that the function will only be called once.

View file

@ -35,10 +35,12 @@ export type VersionedRouteConfig<Method extends RouteMethod> = Omit<
> & {
options?: Omit<
RouteConfigOptions<Method>,
'access' | 'description' | 'deprecated' | 'discontinued'
'access' | 'description' | 'deprecated' | 'discontinued' | 'security'
>;
/** See {@link RouteConfigOptions<RouteMethod>['access']} */
access: Exclude<RouteConfigOptions<Method>['access'], undefined>;
/** See {@link RouteConfigOptions<RouteMethod>['security']} */
security?: Exclude<RouteConfigOptions<Method>['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<P, Q, B> {
* @public
*/
validate: false | VersionedRouteValidation<P, Q, B> | (() => VersionedRouteValidation<P, Q, B>); // Provide a way to lazily load validation schemas
security?: Exclude<RouteConfigOptions<RouteMethod>['security'], undefined>;
}
/**

View file

@ -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';

View file

@ -122,10 +122,12 @@ describe('request logging', () => {
xsrfRequired: false,
access: 'internal',
tags: [],
security: undefined,
timeout: [Object],
body: undefined
}
}
},
authzResult: undefined
}"
`);
});

View file

@ -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:';

View file

@ -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<string, boolean>;
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,
},
});
});
});

View file

@ -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<RouteAuthz>): 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<string, boolean> = {};
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));

View file

@ -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();