[Hardening] Kibana Feature API Privileges Names (#208067)

## Summary

As part of our effort to harden API action definitions and enforce
standards this PR adds an utility `ApiPrivileges` class.
It is supposed to be used for both feature registration and API route
definition to construct the privilege name.
```ts
plugins.features.registerKibanaFeature({
  privileges: {
    all: {
      app: [...],
      catalogue: [...],
      api: [ApiPrivileges.manage('subject_name')],
      ...
    },
    read: {
      ...
      api: [ApiPrivileges.read('subject_name')],
      ...
    },
  },
})
....

// route definition
router.get(
  {
    path: 'api_path',
    security: {
      authz: {
        requiredPrivileges: [ApiPrivileges.manage('subject_name')],
      },
    },
  },
  async (ctx, req, res) => {}
);
```

`require_kibana_feature_privileges_naming` eslint rule has been added to
show warning if the API privilege name doesn't satisfy the naming
convention.

### Naming convention

- API privilege should start with valid `ApiOperation`: `manage`,
`read`, `update`, `delete`, `create`
- API privilege should use `_` as separator

 `read-entity-a`
 `delete_entity-a`
 `entity_manage`
 `read_entity_a`
 `delete_entity_a`
 `manage_entity`

> [!IMPORTANT]  
> Serverless ZDT update scenario:
>
> - version N has an endpoint protected with the `old_privilege_read`.
> - version N+1 has the same endpoint protected with a new
`read_privilege`.
> 
> There might be a short period between the time the UI pod N+1 passes
SO migrations and updates privileges and the time it's marked as
ready-to-handle-requests by k8s, and when UI pod N is terminated.
>
> After discussion with @legrego and @azasypkin we decided to ignore it
due to the perceived risk-to-cost ratio:
> 1. The time window users might be affected is very narrow because we
register privileges late in the Kibana startup flow (e.g., after SO
migrations).
> 2. The transient 403 errors users might get won't result in session
termination and shouldn't lead to data loss.
> 3. The roll-out will be performed in batches over the course of
multiple weeks and implemented by different teams. This means the impact
per release shouldn't be significant.

### 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

__Relates: https://github.com/elastic/kibana/issues/198716__

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Elena Shostak 2025-02-03 15:22:29 +01:00 committed by GitHub
parent 78606e0fcf
commit 504510b92b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 454 additions and 25 deletions

View file

@ -102,6 +102,23 @@ router.get({
}, handler);
```
### Naming conventions for privileges
1. **Privilege should start with a valid `ApiOperation`**:
- **Valid operations**: `manage`, `read`, `update`, `delete`, `create`.
- Use the corresponding methods from the `ApiPrivileges` utility class: `ApiPrivileges.manage`, `ApiPrivileges.read`, etc.
2. **Use `_` as the separator** between the operation and the subject.
**Examples**:
Incorrect privilege names ❌
- `read-entity-a`: Uses `-` instead of `_`.
- `delete_entity-a`: Mixes `_` and `-`.
- `entity_manage`: Places the subject name before the operation.
Correct privilege names ✅
- `read_entity_a`
- `delete_entity_a`
- `manage_entity`
### Configuring operator and superuser privileges
We have two special predefined privilege sets that can be used in security configuration:
1. Operator

View file

@ -322,6 +322,7 @@ module.exports = {
'@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',
'@kbn/eslint/no_this_in_property_initializers': 'error',

View file

@ -21,5 +21,6 @@ module.exports = {
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

@ -0,0 +1,223 @@
/*
* 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 ts = require('typescript');
const path = require('path');
function getImportedVariableValue(context, name, propertyName) {
const parent = context
.getAncestors()
.find((ancestor) => ['BlockStatement', 'Program'].includes(ancestor.type));
if (!parent) return;
const importDeclaration = parent.body.find(
(statement) =>
statement.type === 'ImportDeclaration' &&
statement.specifiers.some((specifier) => specifier.local.name === name)
);
if (!importDeclaration) return;
const absoluteImportPath = require.resolve(importDeclaration.source.value, {
paths: [path.dirname(context.getFilename())],
});
const program = ts.createProgram([absoluteImportPath], {});
const sourceFile = program.getSourceFile(absoluteImportPath);
if (!sourceFile) return null;
const checker = program.getTypeChecker();
const symbols = checker.getExportsOfModule(sourceFile.symbol);
const symbol = symbols.find((s) => s.name === name);
if (!symbol) return null;
if (propertyName) {
const currentSymbol = checker.getTypeOfSymbolAtLocation(symbol, sourceFile);
const property = currentSymbol.getProperty(propertyName);
if (ts.isStringLiteral(property.valueDeclaration.initializer)) {
return property.valueDeclaration.initializer.text;
}
return null;
}
const initializer = symbol?.valueDeclaration?.initializer;
if (ts.isStringLiteral(initializer)) {
return initializer.text;
}
return null;
}
function validatePrivilegesNode(context, privilegesNode, scopedVariables) {
['all', 'read'].forEach((privilegeType) => {
const privilege = privilegesNode.value.properties.find(
(prop) =>
prop.key && prop.key.name === privilegeType && prop.value.type === 'ObjectExpression'
);
if (!privilege) return;
const apiProperty = privilege.value.properties.find(
(prop) => prop.key && prop.key.name === 'api' && prop.value.type === 'ArrayExpression'
);
if (!apiProperty) return;
apiProperty.value.elements.forEach((element) => {
let valueToCheck = null;
if (element.type === 'Literal' && typeof element.value === 'string') {
valueToCheck = element.value;
} else if (element.type === 'Identifier') {
valueToCheck = scopedVariables.has(element.name)
? scopedVariables.get(element.name)
: getImportedVariableValue(context, element.name);
} else if (element.type === 'MemberExpression') {
valueToCheck = getImportedVariableValue(
context,
element.object.name,
element.property.name
);
}
if (valueToCheck) {
const isValid = /^(manage|create|update|delete|read)/.test(valueToCheck);
const usesValidSeparator = /^[a-z0-9_]+$/.test(valueToCheck);
let method = 'manage';
if (valueToCheck.includes('read')) {
method = 'read';
}
if (valueToCheck.includes('create') || valueToCheck.includes('copy')) {
method = 'create';
}
if (valueToCheck.includes('delete')) {
method = 'delete';
}
if (valueToCheck.includes('update')) {
method = 'update';
}
if (!isValid) {
return context.report({
node: element,
message: `API privilege '${valueToCheck}' should start with [manage|create|update|delete|read] or use ApiPrivileges.${method} instead`,
});
}
if (!usesValidSeparator) {
return context.report({
node: element,
message: `API privilege '${valueToCheck}' should use '_' as a separator`,
});
}
}
});
});
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure API privileges in registerKibanaFeature call follow naming conventions',
category: 'Best Practices',
recommended: true,
},
schema: [],
},
create(context) {
return {
CallExpression(node) {
const isRegisterKibanaFeatureCall =
node.callee.type === 'MemberExpression' &&
node.callee.property.name === 'registerKibanaFeature' &&
((node.callee.object.type === 'MemberExpression' &&
node.callee.object.property.name === 'features') ||
node.callee.object.name === 'features');
if (!isRegisterKibanaFeatureCall) return;
const scopedVariables = new Map();
const sourceCode = context.getSourceCode();
const parent = sourceCode
.getAncestors(node)
.find((ancestor) => ['BlockStatement', 'Program'].includes(ancestor.type));
if (parent) {
parent.body.forEach((statement) => {
if (statement.type === 'VariableDeclaration') {
statement.declarations.forEach((declaration) => {
if (
declaration.id.type === 'Identifier' &&
declaration.init &&
declaration.init.type === 'Literal' &&
typeof declaration.init.value === 'string'
) {
scopedVariables.set(declaration.id.name, declaration.init.value);
}
});
}
});
}
const [feature] = node.arguments;
if (feature?.type === 'ObjectExpression') {
const privilegesProperty = feature.properties.find(
(prop) =>
prop.key && prop.key.name === 'privileges' && prop.value.type === 'ObjectExpression'
);
if (!privilegesProperty) return;
return validatePrivilegesNode(context, privilegesProperty, scopedVariables);
}
},
ExportNamedDeclaration(node) {
if (
node.declaration?.type !== 'VariableDeclaration' ||
!node.declaration.declarations?.length
) {
return;
}
node.declaration.declarations.forEach((declaration) => {
if (declaration.init && declaration.init.type === 'ObjectExpression') {
if (
!['id', 'name', 'privileges', 'scope', 'category'].every((key) =>
declaration.init.properties.find((prop) => prop.key?.name === key)
)
) {
return;
}
const privilegesProperty = declaration.init.properties.find(
(prop) =>
prop.key && prop.key.name === 'privileges' && prop.value.type === 'ObjectExpression'
);
validatePrivilegesNode(context, privilegesProperty, new Map());
}
});
},
};
},
};

View file

@ -0,0 +1,140 @@
/*
* 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('./require_kibana_feature_privileges_naming');
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
ecmaFeatures: {
jsx: true,
},
},
});
ruleTester.run('@kbn/require_kibana_feature_privileges_naming', rule, {
valid: [
{
code: `
const privilege = "manage_users";
plugins.features.registerKibanaFeature({
privileges: {
all: {
api: [privilege, "create_logs", "read_logs"],
},
},
});
`,
},
{
code: `
plugins.features.registerKibanaFeature({
privileges: {
all: {
api: ["manage_logs", "create_entries"],
},
},
});
`,
},
{
code: `
features.registerKibanaFeature({
privileges: {
all: {
api: ["read_entries", "update_entries"],
},
},
});
`,
},
{
code: `
const validPrivilege = "delete_users";
const anotherValidPrivilege = "manage_permissions";
plugins.features.registerKibanaFeature({
privileges: {
all: {
api: [validPrivilege, anotherValidPrivilege],
},
},
});
`,
},
],
invalid: [
{
code: `
plugins.features.registerKibanaFeature({
privileges: {
all: {
api: ["incorrect_value", "manage_logs"],
},
},
});
`,
errors: [
{
message: `API privilege 'incorrect_value' should start with [manage|create|update|delete|read] or use ApiPrivileges.manage instead`,
},
],
},
{
code: `
features.registerKibanaFeature({
privileges: {
all: {
api: ["entry_read", "create_logs"],
},
},
});
`,
errors: [
{
message: `API privilege 'entry_read' should start with [manage|create|update|delete|read] or use ApiPrivileges.read instead`,
},
],
},
{
code: `
features.registerKibanaFeature({
privileges: {
all: {
api: ["read_entry-log", "create_logs"],
},
},
});
`,
errors: [
{
message: `API privilege 'read_entry-log' should use '_' as a separator`,
},
],
},
{
code: `
const privilege = 'users-manage';
plugins.features.registerKibanaFeature({
privileges: {
all: {
api: [privilege, "create_logs", "read_logs"],
},
},
});
`,
errors: [
{
message: `API privilege 'users-manage' should start with [manage|create|update|delete|read] or use ApiPrivileges.manage instead`,
},
],
},
],
});

View file

@ -14,7 +14,7 @@ import type {
StatsGetterConfig,
} from '@kbn/telemetry-collection-manager-plugin/server';
import type { SecurityPluginStart } from '@kbn/security-plugin/server';
import { ApiOperation } from '@kbn/security-plugin-types-server';
import { ApiOperation } from '@kbn/security-plugin-types-common';
import { RequestHandler } from '@kbn/core-http-server';
import { FetchSnapshotTelemetry } from '../../common/routes';
import { UsageStatsBody, v2 } from '../../common/types';

View file

@ -34,12 +34,12 @@
"@kbn/analytics-collection-utils",
"@kbn/react-kibana-mount",
"@kbn/core-node-server",
"@kbn/security-plugin-types-server",
"@kbn/core-user-profile-browser-mocks",
"@kbn/core-analytics-browser",
"@kbn/core-analytics-server",
"@kbn/core-elasticsearch-server",
"@kbn/logging",
"@kbn/security-plugin-types-common",
],
"exclude": [
"target/**/*",

View file

@ -7,8 +7,8 @@
import { isString } from 'lodash';
import { ApiOperation } from '@kbn/security-plugin-types-common';
import type { ApiActions as ApiActionsType } from '@kbn/security-plugin-types-server';
import { ApiOperation } from '@kbn/security-plugin-types-server';
export class ApiActions implements ApiActionsType {
private readonly prefix: string;

View file

@ -7,7 +7,7 @@
import { KibanaFeature } from '@kbn/features-plugin/server';
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
import { ApiOperation } from '@kbn/security-plugin-types-server';
import { ApiOperation } from '@kbn/security-plugin-types-common';
import { getReplacedByForPrivilege, privilegesFactory } from './privileges';
import { licenseMock } from '../__fixtures__/licensing.mock';

View file

@ -17,7 +17,7 @@ import {
isMinimalPrivilegeId,
} from '@kbn/security-authorization-core-common';
import type { RawKibanaPrivileges, SecurityLicense } from '@kbn/security-plugin-types-common';
import { ApiOperation } from '@kbn/security-plugin-types-server';
import { ApiOperation } from '@kbn/security-plugin-types-common';
import { featurePrivilegeBuilderFactory } from './feature_privilege_builder';
import type { Actions } from '../actions';

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { isMinimalPrivilegeId, getMinimalPrivilegeId } from './src/privileges';
export { isMinimalPrivilegeId, getMinimalPrivilegeId, ApiPrivileges } from './src/privileges';

View file

@ -0,0 +1,29 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ApiOperation } from '@kbn/security-plugin-types-common';
export class ApiPrivileges {
public static manage(subject: string) {
return `${ApiOperation.Manage}_${subject}`;
}
public static read(subject: string) {
return `${ApiOperation.Read}_${subject}`;
}
public static create(subject: string) {
return `${ApiOperation.Create}_${subject}`;
}
public static update(subject: string) {
return `${ApiOperation.Update}_${subject}`;
}
public static delete(subject: string) {
return `${ApiOperation.Delete}_${subject}`;
}
}

View file

@ -6,3 +6,4 @@
*/
export { isMinimalPrivilegeId, getMinimalPrivilegeId } from './minimal_privileges';
export { ApiPrivileges } from './api_privileges';

View file

@ -6,5 +6,7 @@
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["target/**/*"],
"kbn_references": []
"kbn_references": [
"@kbn/security-plugin-types-common",
]
}

View file

@ -47,3 +47,5 @@ export type {
CategorizedApiKey,
ApiKeyAggregations,
} from './src/api_keys/api_key';
export { ApiOperation } from './src/authorization';

View file

@ -0,0 +1,13 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export enum ApiOperation {
Read = 'read',
Create = 'create',
Update = 'update',
Delete = 'delete',
Manage = 'manage',
}

View file

@ -17,3 +17,4 @@ export type {
RoleRemoteIndexPrivilege,
RoleRemoteClusterPrivilege,
} from './role';
export { ApiOperation } from './api';

View file

@ -89,4 +89,3 @@ export {
getRestApiKeyWithKibanaPrivilegesSchema,
} from './src/authentication';
export { getKibanaRoleSchema, elasticsearchRoleSchema, GLOBAL_RESOURCE } from './src/authorization';
export { ApiOperation } from './src/authorization';

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { ApiOperation } from '@kbn/security-plugin-types-common';
export interface ApiActions {
get(operation: ApiOperation, subject: string): string;
@ -14,11 +16,3 @@ export interface ApiActions {
get(subject: string): string;
actionFromRouteTag(routeTag: string): string;
}
export enum ApiOperation {
Read = 'read',
Create = 'create',
Update = 'update',
Delete = 'delete',
Manage = 'manage',
}

View file

@ -8,7 +8,6 @@
export type { Actions } from './actions';
export type { AlertingActions } from './alerting';
export type { ApiActions } from './api';
export { ApiOperation } from './api';
export type { AppActions } from './app';
export type { CasesActions } from './cases';
export type { SavedObjectActions } from './saved_object';

View file

@ -15,7 +15,6 @@ export type {
SpaceActions,
UIActions,
} from './actions';
export { ApiOperation } from './actions';
export type { AuthorizationServiceSetup } from './authorization_service';
export type {
CheckPrivilegesOptions,

View file

@ -6,6 +6,7 @@
*/
import type { IRouter } from '@kbn/core/server';
import { ApiPrivileges } from '@kbn/security-authorization-core-common';
import {
INSTALLATION_STATUS_API_PATH,
INSTALL_ALL_API_PATH,
@ -32,7 +33,7 @@ export const registerInstallationRoutes = ({
},
security: {
authz: {
requiredPrivileges: ['manage_llm_product_doc'],
requiredPrivileges: [ApiPrivileges.manage('llm_product_doc')],
},
},
},
@ -60,7 +61,7 @@ export const registerInstallationRoutes = ({
},
security: {
authz: {
requiredPrivileges: ['manage_llm_product_doc'],
requiredPrivileges: [ApiPrivileges.manage('llm_product_doc')],
},
},
},
@ -93,7 +94,7 @@ export const registerInstallationRoutes = ({
},
security: {
authz: {
requiredPrivileges: ['manage_llm_product_doc'],
requiredPrivileges: [ApiPrivileges.manage('llm_product_doc')],
},
},
},

View file

@ -26,5 +26,6 @@
"@kbn/licensing-plugin",
"@kbn/task-manager-plugin",
"@kbn/inference-common",
"@kbn/security-authorization-core-common",
]
}

View file

@ -15,6 +15,7 @@ import {
import { mapValues } from 'lodash';
import { i18n } from '@kbn/i18n';
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
import { ApiPrivileges } from '@kbn/security-authorization-core-common';
import { OBSERVABILITY_AI_ASSISTANT_FEATURE_ID } from '../common/feature';
import type { ObservabilityAIAssistantConfig } from './config';
import { registerServerRoutes } from './routes/register_routes';
@ -72,7 +73,11 @@ export class ObservabilityAIAssistantPlugin
privileges: {
all: {
app: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'kibana'],
api: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'ai_assistant', 'manage_llm_product_doc'],
api: [
OBSERVABILITY_AI_ASSISTANT_FEATURE_ID,
'ai_assistant',
ApiPrivileges.manage('llm_product_doc'),
],
catalogue: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID],
savedObject: {
all: [],

View file

@ -50,7 +50,8 @@
"@kbn/core-lifecycle-server",
"@kbn/server-route-repository-utils",
"@kbn/inference-plugin",
"@kbn/ai-assistant-icon"
"@kbn/ai-assistant-icon",
"@kbn/security-authorization-core-common"
],
"exclude": ["target/**/*"]
}

View file

@ -7,7 +7,7 @@
import type { KibanaRequest } from '@kbn/core/server';
import { INTEGRATIONS_PLUGIN_ID, PLUGIN_ID as FLEET_PLUGIN_ID } from '@kbn/fleet-plugin/common';
import { ApiOperation } from '@kbn/security-plugin-types-server';
import { ApiOperation } from '@kbn/security-plugin-types-common';
import type { ProfilingPluginStartDeps } from '../../types';
export async function getHasSetupPrivileges({

View file

@ -55,7 +55,7 @@
"@kbn/deeplinks-observability",
"@kbn/react-kibana-context-render",
"@kbn/apm-data-access-plugin",
"@kbn/security-plugin-types-server"
"@kbn/security-plugin-types-common"
// add references to other TypeScript projects the plugin depends on
// requiredPlugins from ./kibana.json