From 8776fe588d146fa768f912bc6265032576347617 Mon Sep 17 00:00:00 2001 From: Elena Shostak <165678770+elena-shostak@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:26:21 +0200 Subject: [PATCH] [Authz] Eslint Rule for Security Config (#193187) ## Summary ESLint rule is introduced to enforce the migration of access tags in route configurations to the `security.authz.requiredPrivileges` field. It ensures that security configurations are correctly applied in both standard and versioned routes. Will be enabled after https://github.com/elastic/kibana/pull/191973 is merged. The rule covers: - **Access Tag Migration.** Moves `access:` tags from the `options.tags` property to `security.authz.requiredPrivileges`. Preserves any non-access tags in the tags property. - **Missing Security Config Detection.** Reports an error if no security config is found in the route or version. Suggests adding a default security configuration `authz: { enabled: false }`. ### Note There is an indentation issues with the test, `dedent` doesn't solve most of the issues and since `RuleTester` was designed to test a single rule at a time,I couldn't enable multiple fixes (including indent ones) before checking output. Manually adjusted the indentation. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios __Fixes: https://github.com/elastic/kibana/issues/191715__ __Related: https://github.com/elastic/kibana/issues/191710__ --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine --- .../rules/no_deprecated_authz_config.js | 318 +++++++++++++++ .../rules/no_deprecated_authz_config.test.js | 385 ++++++++++++++++++ 2 files changed, 703 insertions(+) create mode 100644 packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.js create mode 100644 packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.test.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.js b/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.js new file mode 100644 index 0000000000000..ca2821c4f8ce6 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.js @@ -0,0 +1,318 @@ +/* + * 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". + */ + +const routeMethods = ['get', 'put', 'delete', 'post']; +const ACCESS_TAG_PREFIX = 'access:'; + +const isStringLiteral = (el) => el.type === 'Literal' && typeof el.value === 'string'; +const isLiteralAccessTag = (el) => isStringLiteral(el) && el.value.startsWith(ACCESS_TAG_PREFIX); +const isLiteralNonAccessTag = (el) => + isStringLiteral(el) && !el.value.startsWith(ACCESS_TAG_PREFIX); + +const isTemplateLiteralAccessTag = (el) => + el.type === 'TemplateLiteral' && el.quasis[0].value.raw.startsWith(ACCESS_TAG_PREFIX); +const isTemplateLiteralNonAccessTag = (el) => + el.type === 'TemplateLiteral' && !el.quasis[0].value.raw.startsWith(ACCESS_TAG_PREFIX); + +const maybeReportDisabledSecurityConfig = (node, context, isVersionedRoute = false) => { + const callee = node.callee; + const isAddVersionCall = + callee.type === 'MemberExpression' && callee.property.name === 'addVersion'; + + const disabledSecurityConfig = ` + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + },`; + + // Skipping root route call intentionally, we will check root route security config in addVersion node traversal + if (isVersionedRoute && !isAddVersionCall) { + return; + } + + if (isVersionedRoute) { + const [versionConfig] = node.arguments; + + if (versionConfig && versionConfig.type === 'ObjectExpression') { + const securityInVersion = versionConfig.properties.find( + (property) => property.key && property.key.name === 'security' + ); + + if (securityInVersion) { + return; + } + + let currentNode = node; + + const hasSecurityInRoot = (config) => { + const securityInRoot = config.properties.find( + (property) => property.key && property.key.name === 'security' + ); + + if (securityInRoot) { + return true; + } + + const optionsProperty = config.properties.find( + (prop) => prop.key && prop.key.name === 'options' + ); + + if (optionsProperty?.value?.properties) { + const tagsProperty = optionsProperty.value.properties.find( + (prop) => prop.key.name === 'tags' + ); + + const accessTagsFilter = (el) => isLiteralAccessTag(el) || isTemplateLiteralAccessTag(el); + const accessTags = tagsProperty.value.elements.filter(accessTagsFilter); + + return accessTags.length > 0; + } + + return false; + }; + + while ( + currentNode && + currentNode.type === 'CallExpression' && + currentNode.callee.type === 'MemberExpression' + ) { + const callee = currentNode.callee; + + if ( + callee.object && + callee.object.property && + callee.object.property.name === 'versioned' && + routeMethods.includes(callee.property.name) + ) { + const [routeConfig] = currentNode.arguments; + + if (routeConfig && routeConfig.type === 'ObjectExpression') { + const securityInRoot = hasSecurityInRoot(routeConfig); + + // If security is missing in both the root and the version + if (!securityInRoot) { + context.report({ + node: versionConfig, + message: 'Security config is missing in addVersion call', + fix(fixer) { + const versionProperty = versionConfig.properties.find( + (property) => property.key && property.key.name === 'version' + ); + const insertPosition = versionProperty.range[1]; + + return fixer.insertTextAfterRange( + [insertPosition, insertPosition + 1], + `${disabledSecurityConfig}` + ); + }, + }); + } + } + + break; + } + + currentNode = callee.object; + } + } + } else { + const [routeConfig] = node.arguments; + const securityProperty = routeConfig.properties.find( + (property) => property.key && property.key.name === 'security' + ); + + if (!securityProperty) { + const pathProperty = routeConfig.properties.find((prop) => prop.key.name === 'path'); + context.report({ + node: routeConfig, + message: 'Security config is missing', + fix(fixer) { + const insertPosition = pathProperty.range[1]; + + return fixer.insertTextAfterRange( + [insertPosition, insertPosition + 1], + `${disabledSecurityConfig}` + ); + }, + }); + } + } +}; + +const handleRouteConfig = (node, context, isVersionedRoute = false) => { + const [routeConfig] = node.arguments; + + if (routeConfig && routeConfig.type === 'ObjectExpression') { + const optionsProperty = routeConfig.properties.find( + (prop) => prop.key && prop.key.name === 'options' + ); + + if (!optionsProperty) { + return maybeReportDisabledSecurityConfig(node, context, isVersionedRoute); + } + + if (optionsProperty?.value?.properties) { + const tagsProperty = optionsProperty.value.properties.find( + (prop) => prop.key.name === 'tags' + ); + + const accessTagsFilter = (el) => isLiteralAccessTag(el) || isTemplateLiteralAccessTag(el); + const nonAccessTagsFilter = (el) => + isLiteralNonAccessTag(el) || isTemplateLiteralNonAccessTag(el); + + const getAccessPrivilege = (el) => { + if (el.type === 'Literal') { + return `'${el.value.split(':')[1]}'`; + } + + if (el.type === 'TemplateLiteral') { + const firstQuasi = el.quasis[0].value.raw; + + if (firstQuasi.startsWith(ACCESS_TAG_PREFIX)) { + const staticPart = firstQuasi.split(ACCESS_TAG_PREFIX)[1] || ''; + + const dynamicParts = el.expressions.map((expression, index) => { + const dynamicPlaceholder = `\${${expression.name}}`; + const nextQuasi = el.quasis[index + 1].value.raw || ''; + return `${dynamicPlaceholder}${nextQuasi}`; + }); + + return `\`${staticPart}${dynamicParts.join('')}\``; + } + } + }; + + if (!tagsProperty) { + return maybeReportDisabledSecurityConfig(node, context, isVersionedRoute); + } + + if (tagsProperty && tagsProperty.value.type === 'ArrayExpression') { + const accessTags = tagsProperty.value.elements.filter(accessTagsFilter); + const nonAccessTags = tagsProperty.value.elements.filter(nonAccessTagsFilter); + + if (!accessTags.length) { + return maybeReportDisabledSecurityConfig(node, context, isVersionedRoute); + } + + context.report({ + node: tagsProperty, + message: `Move 'access' tags to security.authz.requiredPrivileges.`, + fix(fixer) { + const accessPrivileges = accessTags.map(getAccessPrivilege); + + const securityConfig = `security: { + authz: { + requiredPrivileges: [${accessPrivileges.map((priv) => priv).join(', ')}], + }, + }`; + + const sourceCode = context.getSourceCode(); + + const fixes = []; + let remainingOptions = []; + + // If there are non-access tags, keep the 'tags' property with those + if (nonAccessTags.length > 0) { + const nonAccessTagsText = `[${nonAccessTags + .map((tag) => sourceCode.getText(tag)) + .join(', ')}]`; + fixes.push(fixer.replaceText(tagsProperty.value, nonAccessTagsText)); + } else { + // Check if 'options' will be empty after removing 'tags' + remainingOptions = optionsProperty.value.properties.filter( + (prop) => prop.key.name !== 'tags' + ); + + // If options are empty, replace the entire 'options' with 'security' config + if (remainingOptions.length === 0) { + fixes.push(fixer.replaceText(optionsProperty, securityConfig)); + } + } + + // If 'options' was replaced or has other properties, insert security separately + if (remainingOptions.length > 0) { + // If no non-access tags, remove 'tags' + const nextToken = sourceCode.getTokenAfter(tagsProperty); + + if (nextToken && nextToken.value === ',') { + // Remove the 'tags' property and the trailing comma + fixes.push(fixer.removeRange([tagsProperty.range[0], nextToken.range[1]])); + } else { + fixes.push(fixer.remove(tagsProperty)); + } + fixes.push(fixer.insertTextBefore(optionsProperty, `${securityConfig},`)); + } + + if (nonAccessTags.length && !remainingOptions.length) { + fixes.push(fixer.insertTextBefore(optionsProperty, `${securityConfig},`)); + } + + return fixes; + }, + }); + } + } + } +}; + +/** + * ESLint Rule: Migrate `access` tags in route configurations to `security.authz.requiredPrivileges`. + * + * This rule checks for the following in route configurations: + * 1. If a route (e.g., `router.get()`, `router.post()`) contains an `options` property with `tags`. + * 2. If `tags` contains any `access:` tags, these are moved to `security.authz.requiredPrivileges`. + * 3. If no `security` configuration exists, it reports an error and suggests adding a default `security` config. + * 4. It handles both standard routes and versioned routes (e.g., `router.versioned.post()`, `router.addVersion()`). + * 5. If other non-access tags exist, they remain in `tags`. + */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'Migrate routes with and without access tags to security config', + category: 'Best Practices', + recommended: false, + }, + fixable: 'code', + }, + + create(context) { + return { + CallExpression(node) { + const callee = node.callee; + + if ( + callee.type === 'MemberExpression' && + callee.object && + callee.object.name === 'router' && + routeMethods.includes(callee.property.name) + ) { + handleRouteConfig(node, context, false); + } + + if ( + (callee.type === 'MemberExpression' && callee.property.name === 'addVersion') || + (callee.object && + callee.object.type === 'MemberExpression' && + callee.object.object.name === 'router' && + callee.object.property.name === 'versioned' && + routeMethods.includes(callee.property.name)) + ) { + const versionConfig = node.arguments[0]; + + if (versionConfig && versionConfig.type === 'ObjectExpression') { + handleRouteConfig(node, context, true); + } + } + }, + }; + }, +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.test.js b/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.test.js new file mode 100644 index 0000000000000..f0b64da01cf75 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_deprecated_authz_config.test.js @@ -0,0 +1,385 @@ +/* + * 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". + */ + +const { RuleTester } = require('eslint'); +const rule = require('./no_deprecated_authz_config'); +const dedent = require('dedent'); + +// Indentation is a big problem in the test cases, dedent library does not work as expected. + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + }, +}); + +ruleTester.run('no_deprecated_authz_config', rule, { + valid: [ + { + code: ` + router.get( + { + path: '/api/security/authz_poc/simple_privileges_example_1', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization ', + }, + }, + validate: false, + }, + () => {} + ); + `, + name: 'valid: security config is present and authz is disabled', + }, + { + code: ` + router.get({ + path: '/some/path', + options: { + tags: ['otherTag'], + }, + security: { + authz: { + requiredPrivileges: ['somePrivilege'], + }, + }, + }); + `, + name: 'valid: security config is present and authz is enabled', + }, + { + code: ` + router.versioned + .get({ + path: '/some/path', + options: { + tags: ['otherTag'], + }, + }) + .addVersion( + { + version: '1', + validate: false, + security: { + authz: { + requiredPrivileges: ['managePrivileges'], + }, + }, + }, + () => {} + ); + `, + name: 'valid: security config is present for versioned route', + }, + { + code: ` + router.versioned + .get({ + path: '/some/path', + options: { + tags: ['otherTag'], + }, + security: { + authz: { + requiredPrivileges: ['managePrivileges'], + }, + }, + }) + .addVersion( + { + version: '1', + validate: false, + }, + () => {} + ); + `, + name: 'valid: security config is present for versioned route provided in root route definition', + }, + ], + + invalid: [ + { + code: dedent(` + router.get( + { + path: '/test/path', + validate: false, + }, + () => {} + ); + `), + errors: [{ message: 'Security config is missing' }], + output: dedent(` + router.get( + { + path: '/test/path', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: false, + }, + () => {} + ); + `), + name: 'invalid: security config is missing', + }, + { + code: ` + router.get({ + path: '/some/path', + options: { + tags: ['access:securitySolution'], + }, + }); + `, + errors: [{ message: "Move 'access' tags to security.authz.requiredPrivileges." }], + output: ` + router.get({ + path: '/some/path', + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, + }); + `, + name: 'invalid: access tags are string literals, move to security.authz.requiredPrivileges', + }, + { + code: ` + router.get({ + path: '/some/path', + options: { + tags: [\`access:\${APP_ID}-entity-analytics\`], + }, + }); + `, + errors: [{ message: "Move 'access' tags to security.authz.requiredPrivileges." }], + output: ` + router.get({ + path: '/some/path', + security: { + authz: { + requiredPrivileges: [\`\${APP_ID}-entity-analytics\`], + }, + }, + }); + `, + name: 'invalid: access tags are template literals, move to security.authz.requiredPrivileges', + }, + { + code: ` + router.get({ + path: '/some/path', + options: { + tags: ['access:securitySolution', 'otherTag'], + }, + }); + `, + errors: [{ message: "Move 'access' tags to security.authz.requiredPrivileges." }], + output: ` + router.get({ + path: '/some/path', + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + },options: { + tags: ['otherTag'], + }, + }); + `, + name: 'invalid: both access tags and non access tags, move only access tags to security.authz.requiredPrivileges', + }, + { + code: ` + router.versioned + .get({ + path: '/some/path', + options: { + tags: ['access:securitySolution'], + }, + }) + .addVersion( + { + version: '1', + validate: false, + security: { + authz: { + requiredPrivileges: [ApiActionPermission.ManageSpaces], + }, + }, + }, + () => {} + ); + `, + errors: [{ message: "Move 'access' tags to security.authz.requiredPrivileges." }], + output: ` + router.versioned + .get({ + path: '/some/path', + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, + }) + .addVersion( + { + version: '1', + validate: false, + security: { + authz: { + requiredPrivileges: [ApiActionPermission.ManageSpaces], + }, + }, + }, + () => {} + ); + `, + name: 'invalid: versioned route root access tags, move access tags to security.authz.requiredPrivileges', + }, + { + code: ` + router.get({ + path: '/some/path', + options: { + tags: ['access:securitySolution', \`access:\${APP_ID}-entity-analytics\`], + }, + }); + `, + errors: [{ message: "Move 'access' tags to security.authz.requiredPrivileges." }], + output: ` + router.get({ + path: '/some/path', + security: { + authz: { + requiredPrivileges: ['securitySolution', \`\${APP_ID}-entity-analytics\`], + }, + }, + }); + `, + name: 'invalid: string and template literal access tags, move both to security.authz.requiredPrivileges', + }, + { + code: dedent(` + router.versioned + .get({ + path: '/some/path', + options: { + tags: ['otherTag'], + }, + }) + .addVersion( + { + version: '1', + validate: false, + }, + () => {} + ); + `), + errors: [{ message: 'Security config is missing in addVersion call' }], + output: dedent(` + router.versioned + .get({ + path: '/some/path', + options: { + tags: ['otherTag'], + }, + }) + .addVersion( + { + version: '1', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: false, + }, + () => {} + ); + `), + name: 'invalid: security config is missing in addVersion call', + }, + { + code: dedent(` + router.versioned + .get({ + path: '/some/path', + options: { + tags: ['otherTag'], + }, + }) + .addVersion( + { + version: '1', + validate: false, + }, + () => {} + ) + .addVersion( + { + version: '2', + validate: false, + }, + () => {} + ); + `), + errors: [ + { message: 'Security config is missing in addVersion call' }, + { message: 'Security config is missing in addVersion call' }, + ], + output: dedent(` + router.versioned + .get({ + path: '/some/path', + options: { + tags: ['otherTag'], + }, + }) + .addVersion( + { + version: '1', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: false, + }, + () => {} + ) + .addVersion( + { + version: '2', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: false, + }, + () => {} + ); + `), + name: 'invalid: security config is missing in multiple addVersion call', + }, + ], +});