mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
# 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:
parent
1f4ffc4b3f
commit
0b748f0b36
32 changed files with 1540 additions and 38 deletions
|
@ -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!');
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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({}));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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') {
|
||||
|
|
|
@ -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]"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}.`
|
||||
);
|
||||
|
|
|
@ -40,6 +40,7 @@ const createToolkit = (): ToolkitMock => {
|
|||
render: jest.fn(),
|
||||
next: jest.fn(),
|
||||
rewriteUrl: jest.fn(),
|
||||
authzResultNext: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ const createToolkitMock = (): ToolkitMock => {
|
|||
render: jest.fn(),
|
||||
next: jest.fn(),
|
||||
rewriteUrl: jest.fn(),
|
||||
authzResultNext: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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> => ({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -25,6 +25,7 @@ export type {
|
|||
OnPostAuthNextResult,
|
||||
OnPostAuthToolkit,
|
||||
OnPostAuthResult,
|
||||
OnPostAuthAuthzResult,
|
||||
} from './on_post_auth';
|
||||
export { OnPostAuthResultType } from './on_post_auth';
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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}.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -122,10 +122,12 @@ describe('request logging', () => {
|
|||
xsrfRequired: false,
|
||||
access: 'internal',
|
||||
tags: [],
|
||||
security: undefined,
|
||||
timeout: [Object],
|
||||
body: undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
authzResult: undefined
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
|
|
@ -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:';
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue