Skip to content

Commit

Permalink
[Authz] Operator privileges (#196583)
Browse files Browse the repository at this point in the history
## Summary

This PR adds support for explicit indication whether endpoint is
restricted to operator only users.

### Context
1. If user has [all operator
privileges](https://github.com/elastic/elasticsearch/blob/main/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/DefaultOperatorOnlyRegistry.java#L35-#L53)
granted, but is not listed as operator in `operator_users.yml`, ES would
throw an unauthorized error.
2. If user is listed as operator in `operator_users.yml`, but doesn't
have necessary privileges granted, ES would throw an unauthorized error.
3. It’s not possible to determine if a user is operator via any ES API,
i.e. `_has_privileges`.
4. If operator privileges are disabled we skip the the check for it,
that's why we require to explicitly specify additional privileges to
ensure that the route is protected even when operator privileges are
disabled.

### 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/196271__

### How to test

1. Add your user to the operators list
https://github.com/elastic/kibana/blob/1bd81449242a1ab57e82c211808753e82f25c92c/packages/kbn-es/src/serverless_resources/operator_users.yml#L4
or use existing user from the list to log in.
2. Run ES and Kibana serverless
3. Change any endpoint or create a new one with the following security
config
```
      security: {
        authz: {
          requiredPrivileges: [ReservedPrivilegesSet.operator],
        },
      },
```
4. Check with enabled and disabled operator privileges (set
`xpack.security.operator_privileges.enabled`)

## Release Note
Added support for explicit indication whether endpoint is restricted to
operator only users at the route definition level.

---------

Co-authored-by: Elastic Machine <[email protected]>
  • Loading branch information
elena-shostak and elasticmachine authored Dec 12, 2024
1 parent 60399ab commit 52dd7e1
Show file tree
Hide file tree
Showing 14 changed files with 354 additions and 35 deletions.
34 changes: 33 additions & 1 deletion dev_docs/key_concepts/api_authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,38 @@ router.get({
}, handler);
```

### Configuring operator and superuser privileges
We have two special predefined privilege sets that can be used in security configuration:
1. Operator
```ts
router.get({
path: '/api/path',
security: {
authz: {
requiredPrivileges: [ReservedPrivilegesSet.operator, '<privilege_2>'],
},
},
...
}, handler);
```
Operator privileges check is enforced only if operator privileges are enabled in the Elasticsearch configuration.
For more information on operator privileges, refer to the [Operator privileges documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/operator-privileges.html).
If operator privileges are disabled, we skip the check for it, so the only privilege checked from the example above is `<privilege_2>`.
Operator privileges cannot be used as standalone, it is required to explicitly specify additional privileges in the configuration to ensure that the route is protected even when operator privileges are disabled.

2. Superuser
```ts
router.get({
path: '/api/path',
security: {
authz: {
requiredPrivileges: [ReservedPrivilegesSet.superuser],
},
},
...
}, handler);
```

### Opting out of authorization for specific routes
**Before migration:**
```ts
Expand Down Expand Up @@ -291,7 +323,7 @@ GET /api/oas?pathStartsWith=/your/api/path
## Migrating from `access` tags to `security` configuration
We aim to use the same privileges that are currently defined with tags `access:<privilege_name>`.
To assist with this migration, we have created eslint rule `no_deprecated_authz_config`, that will automatically convert your `access` tags to the new `security` configuration.
It scans route definitions and converts `access` tags to the new `requiredPriviliges` configuration.
It scans route definitions and converts `access` tags to the new `requiredPrivileges` configuration.

Note: The rule is disabled by default to avoid automatic migrations without an oversight. You can perform migrations by running:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,16 @@ describe('RouteSecurity validation', () => {
expect(() => validRouteSecurity(routeSecurity)).not.toThrow();
});

it('should pass validation when operator privileges are combined with superuser', () => {
const routeSecurity = {
authz: {
requiredPrivileges: [ReservedPrivilegesSet.operator, ReservedPrivilegesSet.superuser],
},
};

expect(() => validRouteSecurity(routeSecurity)).not.toThrow();
});

it('should fail validation when anyRequired and allRequired have the same values', () => {
const invalidRouteSecurity = {
authz: {
Expand Down Expand Up @@ -263,6 +273,20 @@ describe('RouteSecurity validation', () => {
);
});

it('should fail validation when allRequired has duplicate entries', () => {
const invalidRouteSecurity = {
authz: {
requiredPrivileges: [
{ anyRequired: ['privilege4', 'privilege5'], allRequired: ['privilege1', 'privilege1'] },
],
},
};

expect(() => validRouteSecurity(invalidRouteSecurity)).toThrowErrorMatchingInlineSnapshot(
`"[authz.requiredPrivileges]: allRequired privileges must contain unique values"`
);
});

it('should fail validation when anyRequired has duplicates in multiple privilege entries', () => {
const invalidRouteSecurity = {
authz: {
Expand All @@ -289,7 +313,7 @@ describe('RouteSecurity validation', () => {
};

expect(() => validRouteSecurity(invalidRouteSecurity)).toThrowErrorMatchingInlineSnapshot(
`"[authz.requiredPrivileges]: Combining superuser with other privileges is redundant, superuser privileges set can be only used as a standalone privilege."`
`"[authz.requiredPrivileges]: Using superuser privileges in anyRequired is not allowed"`
);
});

Expand All @@ -304,4 +328,41 @@ describe('RouteSecurity validation', () => {
`"[authz.requiredPrivileges]: Combining superuser with other privileges is redundant, superuser privileges set can be only used as a standalone privilege."`
);
});

it('should fail validation when anyRequired has operator privileges set', () => {
const invalidRouteSecurity = {
authz: {
requiredPrivileges: [
{ anyRequired: ['privilege1', 'privilege2'], allRequired: ['privilege4'] },
{ anyRequired: ['privilege5', ReservedPrivilegesSet.operator] },
],
},
};

expect(() => validRouteSecurity(invalidRouteSecurity)).toThrowErrorMatchingInlineSnapshot(
`"[authz.requiredPrivileges]: Using operator privileges in anyRequired is not allowed"`
);
});

it('should fail validation when operator privileges set is used as standalone', () => {
expect(() =>
validRouteSecurity({
authz: {
requiredPrivileges: [{ allRequired: [ReservedPrivilegesSet.operator] }],
},
})
).toThrowErrorMatchingInlineSnapshot(
`"[authz.requiredPrivileges]: Operator privilege requires at least one additional non-operator privilege to be defined"`
);

expect(() =>
validRouteSecurity({
authz: {
requiredPrivileges: [ReservedPrivilegesSet.operator],
},
})
).toThrowErrorMatchingInlineSnapshot(
`"[authz.requiredPrivileges]: Operator privilege requires at least one additional non-operator privilege to be defined"`
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,33 @@ const requiredPrivilegesSchema = schema.arrayOf(
}
});

if (anyRequired.includes(ReservedPrivilegesSet.superuser)) {
return 'Using superuser privileges in anyRequired is not allowed';
}

const hasSuperuserInAllRequired = allRequired.includes(ReservedPrivilegesSet.superuser);
const hasOperatorInAllRequired = allRequired.includes(ReservedPrivilegesSet.operator);

// Combining superuser with other privileges is redundant.
// If user is a superuser, they inherently have access to all the privileges that may come with other roles.
// The exception is when superuser and operator are the only required privileges.
if (
anyRequired.includes(ReservedPrivilegesSet.superuser) ||
(allRequired.includes(ReservedPrivilegesSet.superuser) && allRequired.length > 1)
hasSuperuserInAllRequired &&
allRequired.length > 1 &&
!(hasOperatorInAllRequired && allRequired.length === 2)
) {
return 'Combining superuser with other privileges is redundant, superuser privileges set can be only used as a standalone privilege.';
}

// Operator privilege requires at least one additional non-operator privilege to be defined, that's why it's not allowed in anyRequired.
if (anyRequired.includes(ReservedPrivilegesSet.operator)) {
return 'Using operator privileges in anyRequired is not allowed';
}

if (hasOperatorInAllRequired && allRequired.length === 1) {
return 'Operator privilege requires at least one additional non-operator privilege to be defined';
}

if (anyRequired.length && allRequired.length) {
for (const privilege of anyRequired) {
if (allRequired.includes(privilege)) {
Expand All @@ -68,12 +86,20 @@ const requiredPrivilegesSchema = schema.arrayOf(
}

if (anyRequired.length) {
const uniquePrivileges = new Set([...anyRequired]);
const uniqueAnyPrivileges = new Set([...anyRequired]);

if (anyRequired.length !== uniquePrivileges.size) {
if (anyRequired.length !== uniqueAnyPrivileges.size) {
return 'anyRequired privileges must contain unique values';
}
}

if (allRequired.length) {
const uniqueAllPrivileges = new Set([...allRequired]);

if (allRequired.length !== uniqueAllPrivileges.size) {
return 'allRequired privileges must contain unique values';
}
}
},
minSize: 1,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,9 @@ export interface AuthenticatedUser extends User {
* User profile ID of this user.
*/
profile_uid?: string;

/**
* Indicates whether user is an operator.
*/
operator?: boolean;
}
1 change: 1 addition & 0 deletions x-pack/packages/security/plugin_types_server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type {
CheckUserProfilesPrivileges,
AuthorizationMode,
AuthorizationServiceSetup,
EsSecurityConfig,
} from './src/authorization';
export type { SecurityPluginSetup, SecurityPluginStart } from './src/plugin';
export type {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*/

import type { Client } from '@elastic/elasticsearch';

type XpackUsageResponse = Awaited<ReturnType<Client['xpack']['usage']>>;
type XpackUsageSecurity = XpackUsageResponse['security'];

export type EsSecurityConfig = Pick<XpackUsageSecurity, 'operator_privileges'>;
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type {
PrivilegeDeprecationsRolesByFeatureIdResponse,
} from './deprecations';
export type { AuthorizationMode } from './mode';
export type { EsSecurityConfig } from './es_security_config';

export { GLOBAL_RESOURCE } from './constants';
export { elasticsearchRoleSchema, getKibanaRoleSchema } from './role_schema';
2 changes: 1 addition & 1 deletion x-pack/packages/security/plugin_types_server/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface SecurityPluginStart {
/**
* Authorization services to manage and access the permissions a particular user has.
*/
authz: AuthorizationServiceSetup;
authz: Omit<AuthorizationServiceSetup, 'getCurrentUser' | 'getSecurityConfig'>;
/**
* User profiles services to retrieve user profiles.
*
Expand Down
Loading

0 comments on commit 52dd7e1

Please sign in to comment.