Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Authz] Operator privileges #196583

Merged
merged 23 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions dev_docs/key_concepts/api_authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,37 @@ 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],
},
},
...
}, 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 fall back to the superuser privileges check.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If operator privileges are disabled, we fall back to the superuser privileges check.

I know we discussed this here: #196271 (comment), but it seems we didn’t reach an agreement. This behavior deviates slightly from Elasticsearch’s approach, making it harder to reason about operator privileges across the Elastic project as a whole. This isn’t necessarily a bad thing, but we should explicitly state the benefits if we decide not to align with Elasticsearch’s treatment of operator privilege requirements.

Is there a reason we can’t or don't want to require developers to explicitly list additional privileges (beyond operator privileges) when exposing a Kibana endpoint to operator users? This would ensure privilege the same checks occur even if the operator user functionality is not enabled. The additional privilege could be superuser, but it could also be a role with fewer privileges if superuser access isn’t strictly necessary. This would discourage admins from granting superuser privileges to operator users unnecessarily.

The only potential issue I see with requiring additional privileges is if there’s a use case where no extra privilege check is needed at the API level because the handler solely relies on user-scoped Saved Objects or Elasticsearch services. If such a use case arises, we could explore solutions to handle it appropriately.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we can’t or don't want to require developers to explicitly list additional privileges

I was for implementing it in the beginning and then thought to make it more feasible in terms of DX. That was a bit premature 😄

The additional privilege could be superuser, but it could also be a role with fewer privileges if superuser access isn’t strictly necessary. This would discourage admins from granting superuser privileges to operator users unnecessarily.

Agree, that makes sense

If there’s a use case where no extra privilege check is needed at the API level because the handler solely relies on user-scoped Saved Objects
Don't think such case has come up yet, but we can explore it when there is the need

Thanks for the feedback, will address the PR accordingly 👍


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
Original file line number Diff line number Diff line change
Expand Up @@ -304,4 +304,19 @@ 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 privilege in anyRequired is not allowed"`
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ const requiredPrivilegesSchema = schema.arrayOf(
return 'Combining superuser with other privileges is redundant, superuser privileges set can be only used as a standalone privilege.';
}

if (anyRequired.includes(ReservedPrivilegesSet.operator)) {
elena-shostak marked this conversation as resolved.
Show resolved Hide resolved
return 'Using operator privilege in anyRequired is not allowed';
}

if (anyRequired.length && allRequired.length) {
for (const privilege of anyRequired) {
if (allRequired.includes(privilege)) {
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;

/**
* Indicated whether user is an operator.
elena-shostak marked this conversation as resolved.
Show resolved Hide resolved
*/
operator?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
* 2.0.
*/

import type { IClusterClient, KibanaRequest } from '@kbn/core/server';
import type { AuthenticatedUser } from '@kbn/security-plugin-types-common';

import type { Actions } from './actions';
import type { CheckPrivilegesWithRequest } from './check_privileges';
import type { CheckPrivilegesDynamicallyWithRequest } from './check_privileges_dynamically';
Expand All @@ -25,4 +28,6 @@ export interface AuthorizationServiceSetup {
checkPrivilegesDynamicallyWithRequest: CheckPrivilegesDynamicallyWithRequest;
checkSavedObjectsPrivilegesWithRequest: CheckSavedObjectsPrivilegesWithRequest;
mode: AuthorizationMode;
getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null;
getClusterClient: () => Promise<IClusterClient>;
}
2 changes: 2 additions & 0 deletions x-pack/plugins/security/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ export type {
UserProfileLabels,
UserProfileUserInfoWithSecurity,
} from '@kbn/security-plugin-types-common';

export { ReservedPrivilegesSet } from './types';
5 changes: 5 additions & 0 deletions x-pack/plugins/security/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ export enum LogoutReason {
export interface SecurityCheckupState {
displayAlert: boolean;
}

export enum ReservedPrivilegesSet {
elena-shostak marked this conversation as resolved.
Show resolved Hide resolved
Operator = 'operator',
Superuser = 'superuser',
}
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,21 @@ describe('initAPIAuthorization', () => {
{
security,
kibanaPrivilegesResponse,
kibanaCurrentUserResponse,
kibanaPrivilegesRequestActions,
asserts,
esXpackUsageResponse,
}: {
security?: RouteSecurity;
kibanaPrivilegesResponse?: {
privileges: { kibana: Array<{ privilege: string; authorized: boolean }> };
hasAllRequested?: boolean;
};
kibanaPrivilegesRequestActions?: string[];
kibanaCurrentUserResponse?: { operator: boolean };
esXpackUsageResponse?: {
security: { operator_privileges: { enabled: boolean; available: boolean } };
};
asserts: {
forbidden?: boolean;
authzResult?: Record<string, boolean>;
Expand Down Expand Up @@ -185,6 +191,14 @@ describe('initAPIAuthorization', () => {
const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit();

const mockCheckPrivileges = jest.fn().mockReturnValue(kibanaPrivilegesResponse);
mockAuthz.getCurrentUser.mockReturnValue(kibanaCurrentUserResponse);
mockAuthz.getClusterClient.mockResolvedValue({
asInternalUser: {
transport: {
request: jest.fn().mockResolvedValue(esXpackUsageResponse),
},
},
});
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
Expand Down Expand Up @@ -356,28 +370,57 @@ describe('initAPIAuthorization', () => {
);

testSecurityConfig(
`protected route returns forbidden if user has allRequired AND NONE of anyRequired privileges requested`,
`protected route returns "authzResult" if user has operator privileges requested and user is operator`,
{
security: {
authz: {
requiredPrivileges: [
{
allRequired: ['privilege1'],
anyRequired: ['privilege2', 'privilege3'],
},
],
requiredPrivileges: [ReservedPrivilegesSet.operator],
},
},
kibanaPrivilegesRequestActions: ['privilege1', 'privilege2', 'privilege3'],
kibanaPrivilegesResponse: {
privileges: {
kibana: [
{ privilege: 'api:privilege1', authorized: true },
{ privilege: 'api:privilege2', authorized: false },
{ privilege: 'api:privilege3', authorized: false },
],
kibanaCurrentUserResponse: { operator: true },
esXpackUsageResponse: {
security: { operator_privileges: { enabled: true, available: true } },
},
asserts: {
authzResult: {
operator: true,
},
},
}
);

testSecurityConfig(
`falls back to 'superuser' privileges check if 'operator' privileges are not enabled`,
{
security: {
authz: {
requiredPrivileges: [ReservedPrivilegesSet.operator],
},
},
esXpackUsageResponse: {
security: { operator_privileges: { enabled: false, available: false } },
},
kibanaPrivilegesResponse: { privileges: { kibana: [] }, hasAllRequested: true },
asserts: {
authzResult: {
superuser: true,
},
},
}
);

testSecurityConfig(
`protected route returns forbidden if user has operator privileges requested and user is not operator`,
{
security: {
authz: {
requiredPrivileges: [ReservedPrivilegesSet.operator],
},
},
esXpackUsageResponse: {
security: { operator_privileges: { enabled: true, available: true } },
},
kibanaCurrentUserResponse: { operator: false },
asserts: {
forbidden: true,
},
Expand Down
73 changes: 68 additions & 5 deletions x-pack/plugins/security/server/authorization/api_authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export function initAPIAuthorization(
checkPrivilegesDynamicallyWithRequest,
checkPrivilegesWithRequest,
mode,
getCurrentUser,
getClusterClient,
}: AuthorizationServiceSetup,
logger: Logger
) {
Expand All @@ -53,15 +55,72 @@ export function initAPIAuthorization(

const authz = security.authz as AuthzEnabled;

const { requestedPrivileges, requestedReservedPrivileges } = authz.requiredPrivileges.reduce(
const normalizeRequiredPrivileges = async (
elena-shostak marked this conversation as resolved.
Show resolved Hide resolved
privileges: AuthzEnabled['requiredPrivileges']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional nit: maybe we need to expose\export Privileges from the Core, but I'm fine with what you have already.

type Privileges = Array<Privilege | PrivilegeSet>;
export interface AuthzEnabled {
  requiredPrivileges: Privileges;
}

) => {
const hasOperatorPrivileges = privileges.some(
(privilege) =>
privilege === ReservedPrivilegesSet.operator ||
(typeof privilege === 'object' &&
privilege.allRequired?.includes(ReservedPrivilegesSet.operator))
);

// nothing to normalize
if (!hasOperatorPrivileges) {
return privileges;
}

const esClient = await getClusterClient();
const operatorPrivilegesConfig = await esClient.asInternalUser.transport.request<{
elena-shostak marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

It's probably fine for now, but this is a hot path and if ever need to a high-performance endpoint to require operator privilege we might need to cache this response for some time.

Copy link
Member

@legrego legrego Dec 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this call is too expensive to put in a hot path such as this. I think we should look into a way to cache this during startup. I recall SDHs where our frequent use of this endpoint elsewhere has caused performance issues on the cluster

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit concerned that if we determine this only once during startup, admins will have to restart Kibana whenever this functionality is enabled or disabled in Elasticsearch. If we want to avoid that risk, we could either cache the result near the invocation point or implement a periodic background job to handle it (which is probably too much comparing to simple cache). Do you think requiring a Kibana restart along with Elasticsearch would be a reasonable approach (similar to how it works for ES, although for the config that doesn't belong to Kibana)?

Alternatively, we can require explicit operator flag/config for Kibana as well, that we can compare with the response from ES at startup time and bail if they don't match... Not sure if I like this option though

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd share your concerns if this was a different ES setting. Operator Privileges are only used by our orchestrated offerings, and are configured via files that reside on each node. I do not expect the same cluster to switch modes at runtime, and we explicitly do not support direct usage of this feature:

https://www.elastic.co/guide/en/elasticsearch/reference/current/operator-privileges.html
Larry Gregory 2024-12-05 at 11 06 18

Since we are in control over the configuration of Operator Privileges, it seems like it'd be sufficient to check this once on startup, and require a restart in the event the cluster somehow switched between using Operator Privileges and not using Operator Privileges.

Copy link
Contributor Author

@elena-shostak elena-shostak Dec 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We make a call to operator privileges config only on startup now

security: { operator_privileges: { enabled: boolean; available: boolean } };
}>({
method: 'GET',
path: '/_xpack/usage?filter_path=security.operator_privileges',
});

// nothing to normalize
if (operatorPrivilegesConfig.security.operator_privileges.enabled) {
return privileges;
}

return privileges.map((privilege) => {
if (typeof privilege === 'object') {
const operatorPrivilegeIndex =
elena-shostak marked this conversation as resolved.
Show resolved Hide resolved
privilege.allRequired?.findIndex((p) => p === ReservedPrivilegesSet.operator) ?? -1;

return operatorPrivilegeIndex !== -1
? {
anyRequired: privilege.anyRequired,
// @ts-expect-error wrong types for `toSpliced`
allRequired: privilege.allRequired?.toSpliced(
operatorPrivilegeIndex,
1,
ReservedPrivilegesSet.superuser
),
}
: privilege;
}

return privilege === ReservedPrivilegesSet.operator
? ReservedPrivilegesSet.superuser
: privilege;
});
};

const requiredPrivileges = await normalizeRequiredPrivileges(authz.requiredPrivileges);

const { requestedPrivileges, requestedReservedPrivileges } = requiredPrivileges.reduce(
(acc, privilegeEntry) => {
const privileges =
typeof privilegeEntry === 'object'
? [...(privilegeEntry.allRequired ?? []), ...(privilegeEntry.anyRequired ?? [])]
: [privilegeEntry];

for (const privilege of privileges) {
if (isReservedPrivilegeSet(privilege)) {
if (
isReservedPrivilegeSet(privilege) &&
!acc.requestedReservedPrivileges.includes(privilege)
) {
acc.requestedReservedPrivileges.push(privilege);
} else {
acc.requestedPrivileges.push(privilege);
Expand Down Expand Up @@ -97,10 +156,14 @@ export function initAPIAuthorization(
const checkSuperuserPrivilegesResponse = await checkPrivilegesWithRequest(
request
).globally(SUPERUSER_PRIVILEGES);

kibanaPrivileges[ReservedPrivilegesSet.superuser] =
checkSuperuserPrivilegesResponse.hasAllRequested;
}

if (reservedPrivilege === ReservedPrivilegesSet.operator) {
const currentUser = getCurrentUser(request);
kibanaPrivileges[ReservedPrivilegesSet.operator] = currentUser?.operator ?? false;
}
}

const hasRequestedPrivilege = (kbPrivilege: Privilege | PrivilegeSet) => {
Expand All @@ -118,8 +181,8 @@ export function initAPIAuthorization(
return kibanaPrivileges[kbPrivilege];
};

for (const requiredPrivilege of authz.requiredPrivileges) {
if (!hasRequestedPrivilege(requiredPrivilege)) {
for (const privilege of requiredPrivileges) {
if (!hasRequestedPrivilege(privilege)) {
const missingPrivileges = Object.keys(kibanaPrivileges).filter(
(key) => !kibanaPrivileges[key]
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ export class AuthorizationService {
checkPrivilegesWithRequest,
getSpacesService
),
getCurrentUser,
getClusterClient,
};

capabilities.registerSwitcher(
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/security/server/authorization/index.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,7 @@ export const authorizationMock = {
privileges: { get: jest.fn() },
registerPrivilegesWithCluster: jest.fn(),
disableUnauthorizedCapabilities: jest.fn(),
getCurrentUser: jest.fn(),
getClusterClient: jest.fn(),
}),
};
4 changes: 4 additions & 0 deletions x-pack/plugins/security/server/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ function createSetupMock() {
checkPrivilegesDynamicallyWithRequest: mockAuthz.checkPrivilegesDynamicallyWithRequest,
checkSavedObjectsPrivilegesWithRequest: mockAuthz.checkSavedObjectsPrivilegesWithRequest,
mode: mockAuthz.mode,
getCurrentUser: mockAuthz.getCurrentUser,
getClusterClient: mockAuthz.getClusterClient,
},
registerSpacesService: jest.fn(),
license: licenseMock.create(),
Expand All @@ -54,6 +56,8 @@ function createStartMock() {
checkPrivilegesDynamicallyWithRequest: mockAuthz.checkPrivilegesDynamicallyWithRequest,
checkSavedObjectsPrivilegesWithRequest: mockAuthz.checkSavedObjectsPrivilegesWithRequest,
mode: mockAuthz.mode,
getClusterClient: mockAuthz.getClusterClient,
getCurrentUser: mockAuthz.getCurrentUser,
},
userProfiles: {
getCurrent: mockUserProfiles.getCurrent,
Expand Down
4 changes: 4 additions & 0 deletions x-pack/plugins/security/server/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ describe('Security Plugin', () => {
"checkPrivilegesDynamicallyWithRequest": [Function],
"checkPrivilegesWithRequest": [Function],
"checkSavedObjectsPrivilegesWithRequest": [Function],
"getClusterClient": [Function],
"getCurrentUser": [Function],
"mode": Object {
"useRbacForRequest": [Function],
},
Expand Down Expand Up @@ -210,6 +212,8 @@ describe('Security Plugin', () => {
"checkPrivilegesDynamicallyWithRequest": [Function],
"checkPrivilegesWithRequest": [Function],
"checkSavedObjectsPrivilegesWithRequest": [Function],
"getClusterClient": [Function],
"getCurrentUser": [Function],
"mode": Object {
"useRbacForRequest": [Function],
},
Expand Down
Loading