[Authz] Cleanup of access tags functionality and documentation (#220231)

## Summary

Mandatory security config has been added in
https://github.com/elastic/kibana/pull/215180. This PR cleans up access
tags functionality, documentation and migration eslint rule
`no_deprecated_authz_config` that is no longer needed.


### Checklist
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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
This commit is contained in:
Elena Shostak 2025-05-22 08:45:17 +02:00 committed by GitHub
parent ddb98d3656
commit c3a26c32e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 252 additions and 1125 deletions

View file

@ -318,7 +318,6 @@ module.exports = {
'@kbn/disable/no_naked_eslint_disable': 'error',
'@kbn/eslint/no_async_promise_body': 'error',
'@kbn/eslint/no_async_foreach': 'error',
'@kbn/eslint/no_deprecated_authz_config': 'error',
'@kbn/eslint/require_kibana_feature_privileges_naming': 'warn',
'@kbn/eslint/no_trailing_import_slash': 'error',
'@kbn/eslint/no_constructor_args_in_property_initializers': 'error',

View file

@ -20,7 +20,6 @@ module.exports = {
no_this_in_property_initializers: require('./rules/no_this_in_property_initializers'),
no_unsafe_console: require('./rules/no_unsafe_console'),
no_unsafe_hash: require('./rules/no_unsafe_hash'),
no_deprecated_authz_config: require('./rules/no_deprecated_authz_config'),
require_kibana_feature_privileges_naming: require('./rules/require_kibana_feature_privileges_naming'),
},
};

View file

@ -1,345 +0,0 @@
/*
* 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', 'patch'];
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 isTemplateLiteralAccessTag = (el) =>
el.type === 'TemplateLiteral' && el.quasis[0].value.raw.startsWith(ACCESS_TAG_PREFIX);
const maybeReportDisabledSecurityConfig = (node, context, isVersionedRoute = false) => {
// Allow disabling migration for routes that are opted out from authorization
if (process.env.MIGRATE_DISABLED_AUTHZ === 'false') {
return;
}
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;
}
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;
};
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;
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 pathProperty = routeConfig.properties?.find((prop) => prop?.key?.name === 'path');
if (!pathProperty) {
return;
}
if (!hasSecurityInRoot(routeConfig)) {
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) =>
!isLiteralAccessTag(el) && !isTemplateLiteralAccessTag(el);
const getAccessPrivilege = (el) => {
if (el.type === 'Literal') {
return `'${el.value.split(ACCESS_TAG_PREFIX)[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) => {
let dynamicPlaceholder;
if (expression.property) {
// Case: object.property
dynamicPlaceholder = `\${${expression.object.name}.${expression.property.name}}`;
} else {
// Case: simple variable
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:<privilege>` 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;
// Skipping by default if any of env vars is not set
const shouldSkipMigration =
!process.env.MIGRATE_ENABLED_AUTHZ && !process.env.MIGRATE_DISABLED_AUTHZ;
if (shouldSkipMigration) {
return;
}
if (
callee.type === 'MemberExpression' &&
callee.object &&
callee.object.name === 'router' &&
routeMethods.includes(callee.property.name)
) {
if (process.env.MIGRATE_ENABLED_AUTHZ === 'false') {
maybeReportDisabledSecurityConfig(node, context, false);
} else {
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') {
if (process.env.MIGRATE_ENABLED_AUTHZ === 'false') {
maybeReportDisabledSecurityConfig(node, context, true);
} else {
handleRouteConfig(node, context, true);
}
}
}
},
};
},
};

View file

@ -1,462 +0,0 @@
/*
* 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');
beforeAll(() => {
process.env.MIGRATE_ENABLED_AUTHZ = 'true';
process.env.MIGRATE_DISABLED_AUTHZ = 'true';
});
afterAll(() => {
delete process.env.MIGRATE_ENABLED_AUTHZ;
delete process.env.MIGRATE_DISABLED_AUTHZ;
});
// 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:ml:someTag', 'access:prefix:someTag'],
},
});
`,
errors: [{ message: "Move 'access' tags to security.authz.requiredPrivileges." }],
output: `
router.get({
path: '/some/path',
security: {
authz: {
requiredPrivileges: ['ml:someTag', 'prefix:someTag'],
},
},
});
`,
name: 'invalid: access tags have multiple prefixes, 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:\${APP.TEST_ID}\`],
},
});
`,
errors: [{ message: "Move 'access' tags to security.authz.requiredPrivileges." }],
output: `
router.get({
path: '/some/path',
security: {
authz: {
requiredPrivileges: [\`\${APP.TEST_ID}\`],
},
},
});
`,
name: 'invalid: access tags are template literals, move to security.authz.requiredPrivileges',
},
{
code: `
router.get({
path: '/some/path',
options: {
tags: ['access:securitySolution', routeTagHelper('someTag')],
},
});
`,
errors: [{ message: "Move 'access' tags to security.authz.requiredPrivileges." }],
output: `
router.get({
path: '/some/path',
security: {
authz: {
requiredPrivileges: ['securitySolution'],
},
},options: {
tags: [routeTagHelper('someTag')],
},
});
`,
name: 'invalid: access tags and tags made with helper function, only access tags are moved 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',
},
],
});