Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type {
GrantUiamAPIKeyParams,
InvalidateUiamAPIKeyParams,
UiamAPIKeysType,
ClientAuthentication,
} from './src/authentication';
export type {
PrivilegeDeprecationsService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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.
*/

/**
* Represents client authentication information. Don't confuse with the authentication information which represents an
* authenticated user. For example, if Kibana is making a request to Elasticsearch on behalf of an authenticated user, the
* client authentication information would represent Kibana's own authentication information (e.g. shared secret), not the
* end user's.
*/
export interface ClientAuthentication {
/**
* The authentication scheme. Currently only `SharedSecret` scheme is supported.
*/
readonly scheme: 'SharedSecret' | string;

/**
* The authentication credentials for the scheme.
*/
readonly value: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

export type { AuthenticationServiceStart } from './authentication_service';
export type { ClientAuthentication } from './client_authentication';

export type {
NativeAPIKeysType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { APIKeys } from './api_keys';
import type { SecurityLicense } from '../../../common';
import { ALL_SPACES_ID } from '../../../common/constants';
import { licenseMock } from '../../../common/licensing/index.mock';
import { uiamServiceMock } from '../../uiam/uiam_service.mock';

const encodeToBase64 = (str: string) => Buffer.from(str).toString('base64');

Expand Down Expand Up @@ -626,6 +627,167 @@ describe('API Keys', () => {
expect(mockValidateKibanaPrivileges).not.toHaveBeenCalled();
expect(mockClusterClient.asInternalUser.security.grantApiKey).not.toHaveBeenCalled();
});

describe('with UIAM', () => {
it('uses UIAM client authentication when credentials are UIAM credentials', async () => {
const mockUiam = uiamServiceMock.create();
mockUiam.getClientAuthentication.mockReturnValue({
scheme: 'SharedSecret',
value: 'uiam-shared-secret',
});
const apiKeysWithUiam = new APIKeys({
clusterClient: mockClusterClient,
logger,
license: mockLicense,
applicationName: 'kibana-.kibana',
kibanaFeatures: [],
uiam: mockUiam,
});

mockClusterClient.asInternalUser.security.grantApiKey.mockResponseOnce({
id: '123',
name: 'key-name',
api_key: 'abc123',
encoded: 'utf8',
});

const result = await apiKeysWithUiam.grantAsInternalUser(
httpServerMock.createKibanaRequest({
headers: {
authorization: `Bearer essu_uiam_access_token`,
},
}),
{
name: 'test_api_key',
role_descriptors: roleDescriptors,
expiration: '1d',
}
);

expect(result).toEqual({
api_key: 'abc123',
id: '123',
name: 'key-name',
encoded: 'utf8',
});
expect(mockUiam.getClientAuthentication).toHaveBeenCalled();
expect(mockClusterClient.asInternalUser.security.grantApiKey).toHaveBeenCalledWith({
api_key: {
name: 'test_api_key',
role_descriptors: roleDescriptors,
expiration: '1d',
},
grant_type: 'access_token',
access_token: 'essu_uiam_access_token',
client_authentication: {
scheme: 'SharedSecret',
value: 'uiam-shared-secret',
},
});
});

it('ignores es-client-authentication header when credentials are UIAM credentials', async () => {
const mockUiam = uiamServiceMock.create();
mockUiam.getClientAuthentication.mockReturnValue({
scheme: 'SharedSecret',
value: 'uiam-shared-secret',
});
const apiKeysWithUiam = new APIKeys({
clusterClient: mockClusterClient,
logger,
license: mockLicense,
applicationName: 'kibana-.kibana',
kibanaFeatures: [],
uiam: mockUiam,
});

mockClusterClient.asInternalUser.security.grantApiKey.mockResponseOnce({
id: '123',
name: 'key-name',
api_key: 'abc123',
encoded: 'utf8',
});

await apiKeysWithUiam.grantAsInternalUser(
httpServerMock.createKibanaRequest({
headers: {
authorization: `Bearer essu_uiam_access_token`,
'es-client-authentication': 'SharedSecret should-be-ignored',
},
}),
{
name: 'test_api_key',
role_descriptors: roleDescriptors,
expiration: '1d',
}
);

// Should use UIAM client authentication, not the es-client-authentication header
expect(mockUiam.getClientAuthentication).toHaveBeenCalled();
expect(mockClusterClient.asInternalUser.security.grantApiKey).toHaveBeenCalledWith({
api_key: {
name: 'test_api_key',
role_descriptors: roleDescriptors,
expiration: '1d',
},
grant_type: 'access_token',
access_token: 'essu_uiam_access_token',
client_authentication: {
scheme: 'SharedSecret',
value: 'uiam-shared-secret',
},
});
});

it('uses es-client-authentication header when UIAM is configured but credentials are not UIAM credentials', async () => {
const mockUiam = uiamServiceMock.create();
const apiKeysWithUiam = new APIKeys({
clusterClient: mockClusterClient,
logger,
license: mockLicense,
applicationName: 'kibana-.kibana',
kibanaFeatures: [],
uiam: mockUiam,
});

mockClusterClient.asInternalUser.security.grantApiKey.mockResponseOnce({
id: '123',
name: 'key-name',
api_key: 'abc123',
encoded: 'utf8',
});

await apiKeysWithUiam.grantAsInternalUser(
httpServerMock.createKibanaRequest({
headers: {
authorization: `Bearer regular_access_token`,
'es-client-authentication': 'SharedSecret header-secret',
},
}),
{
name: 'test_api_key',
role_descriptors: roleDescriptors,
expiration: '1d',
}
);

// Should NOT use UIAM client authentication since credentials are not UIAM credentials
expect(mockUiam.getClientAuthentication).not.toHaveBeenCalled();
expect(mockClusterClient.asInternalUser.security.grantApiKey).toHaveBeenCalledWith({
api_key: {
name: 'test_api_key',
role_descriptors: roleDescriptors,
expiration: '1d',
},
grant_type: 'access_token',
access_token: 'regular_access_token',
client_authentication: {
scheme: 'SharedSecret',
value: 'header-secret',
},
});
});
});
});

describe('invalidate()', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { BuildFlavor } from '@kbn/config';
import type { IClusterClient, KibanaRequest, Logger } from '@kbn/core/server';
import type { KibanaFeature } from '@kbn/features-plugin/server';
import type {
ClientAuthentication,
CreateAPIKeyParams,
CreateAPIKeyResult,
CreateRestAPIKeyParams,
Expand All @@ -25,8 +26,13 @@ import { isCreateRestAPIKeyParams } from '@kbn/security-plugin-types-server';

import { getFakeKibanaRequest } from './fake_kibana_request';
import type { SecurityLicense } from '../../../common';
import { transformPrivilegesToElasticsearchPrivileges, validateKibanaPrivileges } from '../../lib';
import {
getScopedClient,
transformPrivilegesToElasticsearchPrivileges,
validateKibanaPrivileges,
} from '../../lib';
import type { UpdateAPIKeyParams, UpdateAPIKeyResult } from '../../routes/api_keys';
import { isUiamCredential, type UiamServicePublic } from '../../uiam';
import {
BasicHTTPAuthorizationHeaderCredentials,
HTTPAuthorizationHeader,
Expand All @@ -47,6 +53,7 @@ export interface ConstructorOptions {
applicationName: string;
kibanaFeatures: KibanaFeature[];
buildFlavor?: BuildFlavor;
uiam?: UiamServicePublic;
}

type GrantAPIKeyParams =
Expand All @@ -72,6 +79,7 @@ export class APIKeys implements NativeAPIKeysType {
private readonly applicationName: string;
private readonly kibanaFeatures: KibanaFeature[];
private readonly buildFlavor?: BuildFlavor;
private readonly uiam?: UiamServicePublic;

constructor({
logger,
Expand All @@ -80,13 +88,15 @@ export class APIKeys implements NativeAPIKeysType {
applicationName,
kibanaFeatures,
buildFlavor,
uiam,
}: ConstructorOptions) {
this.logger = logger;
this.clusterClient = clusterClient;
this.license = license;
this.applicationName = applicationName;
this.kibanaFeatures = kibanaFeatures;
this.buildFlavor = buildFlavor;
this.uiam = uiam;
}

/**
Expand Down Expand Up @@ -156,7 +166,7 @@ export class APIKeys implements NativeAPIKeysType {
return null;
}
const { type, expiration, name, metadata } = createParams;
const scopedClusterClient = this.clusterClient.asScoped(request);
const scopedClusterClient = getScopedClient(request, this.clusterClient, this.uiam);

this.logger.debug('Trying to create an API key');

Expand Down Expand Up @@ -211,7 +221,7 @@ export class APIKeys implements NativeAPIKeysType {
}

const { type, id, metadata } = updateParams;
const scopedClusterClient = this.clusterClient.asScoped(request);
const scopedClusterClient = getScopedClient(request, this.clusterClient, this.uiam);

this.logger.debug('Trying to edit an API key');

Expand Down Expand Up @@ -271,11 +281,26 @@ export class APIKeys implements NativeAPIKeysType {
);
}

// Try to extract optional Elasticsearch client credentials (currently only used by JWT).
const clientAuthorizationHeader = HTTPAuthorizationHeader.parseFromRequest(
request,
ELASTICSEARCH_CLIENT_AUTHENTICATION_HEADER
);
// If API key is granted for UIAM credentials, we need to pass UIAM client authentication and ignore any other
// client credentials that might have been provided. Otherwise, try to extract optional Elasticsearch client
// credentials from `es-client-authentication` HTTP header (currently only used by JWT).
let clientAuthentication: ClientAuthentication | undefined;

if (this.uiam && isUiamCredential(authorizationHeader)) {
clientAuthentication = this.uiam.getClientAuthentication();
} else {
const clientAuthorizationHeader = HTTPAuthorizationHeader.parseFromRequest(
request,
ELASTICSEARCH_CLIENT_AUTHENTICATION_HEADER
);

if (clientAuthorizationHeader) {
clientAuthentication = {
scheme: clientAuthorizationHeader.scheme,
value: clientAuthorizationHeader.credentials,
};
}
}

const { expiration, metadata, name } = createParams;
const roleDescriptors =
Expand All @@ -290,7 +315,7 @@ export class APIKeys implements NativeAPIKeysType {
const params = this.getGrantParams(
{ expiration, metadata, name, role_descriptors: roleDescriptors },
authorizationHeader,
clientAuthorizationHeader
clientAuthentication
);
// User needs `manage_api_key` or `grant_api_key` privilege to use this API
let result: GrantAPIKeyResult;
Expand Down Expand Up @@ -319,7 +344,11 @@ export class APIKeys implements NativeAPIKeysType {
let result: InvalidateAPIKeyResult;
try {
// User needs `manage_api_key` privilege to use this API
result = await this.clusterClient.asScoped(request).asCurrentUser.security.invalidateApiKey({
result = await getScopedClient(
request,
this.clusterClient,
this.uiam
).asCurrentUser.security.invalidateApiKey({
ids: params.ids,
});
this.logger.debug(
Expand Down Expand Up @@ -379,7 +408,11 @@ export class APIKeys implements NativeAPIKeysType {

this.logger.debug(`Trying to validate an API key`);
try {
await this.clusterClient.asScoped(fakeRequest).asCurrentUser.security.authenticate();
await getScopedClient(
fakeRequest,
this.clusterClient,
this.uiam
).asCurrentUser.security.authenticate();
this.logger.debug(`API key was validated successfully`);
return true;
} catch (e) {
Expand All @@ -403,21 +436,14 @@ export class APIKeys implements NativeAPIKeysType {
private getGrantParams(
createParams: CreateRestAPIKeyParams | CreateRestAPIKeyWithKibanaPrivilegesParams,
authorizationHeader: HTTPAuthorizationHeader,
clientAuthorizationHeader: HTTPAuthorizationHeader | null
clientAuthentication?: ClientAuthentication
): GrantAPIKeyParams {
if (authorizationHeader.scheme.toLowerCase() === 'bearer') {
return {
api_key: createParams,
grant_type: 'access_token',
access_token: authorizationHeader.credentials,
...(clientAuthorizationHeader
? {
client_authentication: {
scheme: clientAuthorizationHeader.scheme,
value: clientAuthorizationHeader.credentials,
},
}
: {}),
...(clientAuthentication ? { client_authentication: clientAuthentication } : {}),
};
}

Expand Down
Loading