From 0e8a251019673136214f29147bcfa143e1ecb343 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 22 Jan 2026 14:49:51 +0100 Subject: [PATCH 1/2] feat(security, uiam): add support for UIAM credentials in authorization and native ES API keys APIs --- .../security/plugin_types_server/index.ts | 1 + .../authentication/client_authentication.ts | 24 +++++++ .../src/authentication/index.ts | 1 + .../authentication/api_keys/api_keys.ts | 68 +++++++++++++------ .../api_keys/uiam/uiam_api_keys.test.ts | 28 +------- .../api_keys/uiam/uiam_api_keys.ts | 21 ++---- .../authentication/authentication_service.ts | 1 + .../authentication/providers/saml.test.ts | 25 +++---- .../server/authentication/providers/saml.ts | 15 +++- .../authorization_service.test.ts | 5 ++ .../authorization/authorization_service.ts | 4 ++ .../authorization/check_privileges.test.ts | 7 ++ .../server/authorization/check_privileges.ts | 29 +++++++- .../plugins/shared/security/server/plugin.ts | 12 +++- .../shared/security/server/uiam/index.ts | 1 + .../security/server/uiam/uiam_service.mock.ts | 2 +- .../security/server/uiam/uiam_service.test.ts | 11 ++- .../security/server/uiam/uiam_service.ts | 26 +++---- .../shared/security/server/uiam/utils.test.ts | 30 ++++++++ .../shared/security/server/uiam/utils.ts | 22 ++++++ .../server/user_profile/user_profile_grant.ts | 4 +- .../user_profile/user_profile_service.test.ts | 2 +- .../user_profile/user_profile_service.ts | 2 +- 23 files changed, 231 insertions(+), 110 deletions(-) create mode 100644 x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/client_authentication.ts create mode 100644 x-pack/platform/plugins/shared/security/server/uiam/utils.test.ts create mode 100644 x-pack/platform/plugins/shared/security/server/uiam/utils.ts diff --git a/x-pack/platform/packages/shared/security/plugin_types_server/index.ts b/x-pack/platform/packages/shared/security/plugin_types_server/index.ts index 395456b122b3d..699e48c328cad 100644 --- a/x-pack/platform/packages/shared/security/plugin_types_server/index.ts +++ b/x-pack/platform/packages/shared/security/plugin_types_server/index.ts @@ -24,6 +24,7 @@ export type { GrantUiamAPIKeyParams, InvalidateUiamAPIKeyParams, UiamAPIKeysType, + ClientAuthentication, } from './src/authentication'; export type { PrivilegeDeprecationsService, diff --git a/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/client_authentication.ts b/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/client_authentication.ts new file mode 100644 index 0000000000000..3d02f7232f930 --- /dev/null +++ b/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/client_authentication.ts @@ -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; +} diff --git a/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/index.ts b/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/index.ts index 5e62d3464c039..4da9dd4a9c37a 100644 --- a/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/index.ts +++ b/x-pack/platform/packages/shared/security/plugin_types_server/src/authentication/index.ts @@ -6,6 +6,7 @@ */ export type { AuthenticationServiceStart } from './authentication_service'; +export type { ClientAuthentication } from './client_authentication'; export type { NativeAPIKeysType, diff --git a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.ts b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.ts index 64ecdc11f3d50..31d58e7716c6b 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.ts @@ -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, @@ -27,6 +28,7 @@ import { getFakeKibanaRequest } from './fake_kibana_request'; import type { SecurityLicense } from '../../../common'; import { transformPrivilegesToElasticsearchPrivileges, validateKibanaPrivileges } from '../../lib'; import type { UpdateAPIKeyParams, UpdateAPIKeyResult } from '../../routes/api_keys'; +import { isUiamCredential, type UiamServicePublic } from '../../uiam'; import { BasicHTTPAuthorizationHeaderCredentials, HTTPAuthorizationHeader, @@ -47,6 +49,7 @@ export interface ConstructorOptions { applicationName: string; kibanaFeatures: KibanaFeature[]; buildFlavor?: BuildFlavor; + uiam?: UiamServicePublic; } type GrantAPIKeyParams = @@ -72,6 +75,7 @@ export class APIKeys implements NativeAPIKeysType { private readonly applicationName: string; private readonly kibanaFeatures: KibanaFeature[]; private readonly buildFlavor?: BuildFlavor; + private readonly uiam?: UiamServicePublic; constructor({ logger, @@ -80,6 +84,7 @@ export class APIKeys implements NativeAPIKeysType { applicationName, kibanaFeatures, buildFlavor, + uiam, }: ConstructorOptions) { this.logger = logger; this.clusterClient = clusterClient; @@ -87,6 +92,7 @@ export class APIKeys implements NativeAPIKeysType { this.applicationName = applicationName; this.kibanaFeatures = kibanaFeatures; this.buildFlavor = buildFlavor; + this.uiam = uiam; } /** @@ -156,7 +162,7 @@ export class APIKeys implements NativeAPIKeysType { return null; } const { type, expiration, name, metadata } = createParams; - const scopedClusterClient = this.clusterClient.asScoped(request); + const scopedClusterClient = this.getScopedClient(request); this.logger.debug('Trying to create an API key'); @@ -211,7 +217,7 @@ export class APIKeys implements NativeAPIKeysType { } const { type, id, metadata } = updateParams; - const scopedClusterClient = this.clusterClient.asScoped(request); + const scopedClusterClient = this.getScopedClient(request); this.logger.debug('Trying to edit an API key'); @@ -271,11 +277,24 @@ 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 = @@ -290,7 +309,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; @@ -319,7 +338,7 @@ 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 this.getScopedClient(request).asCurrentUser.security.invalidateApiKey({ ids: params.ids, }); this.logger.debug( @@ -379,7 +398,7 @@ export class APIKeys implements NativeAPIKeysType { this.logger.debug(`Trying to validate an API key`); try { - await this.clusterClient.asScoped(fakeRequest).asCurrentUser.security.authenticate(); + await this.getScopedClient(fakeRequest).asCurrentUser.security.authenticate(); this.logger.debug(`API key was validated successfully`); return true; } catch (e) { @@ -403,21 +422,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 } : {}), }; } @@ -479,6 +491,24 @@ export class APIKeys implements NativeAPIKeysType { return roleDescriptors; } + + private getScopedClient(request: KibanaRequest) { + // If we're not in UIAM mode or if the request is not a fake request, use request scope directly. + if (!this.uiam || request.isFakeRequest === false) { + return this.clusterClient.asScoped(request); + } + + // In UIAM mode and for fake requests, it's still possible that the request is authenticated with non-UIAM credentials. + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (!authorizationHeader || !isUiamCredential(authorizationHeader)) { + return this.clusterClient.asScoped(request); + } + + // For UIAM credentials, we need to add the UIAM authentication header to the scoped client. + return this.clusterClient.asScoped({ + headers: { ...request.headers, ...this.uiam.getEsClientAuthenticationHeader() }, + }); + } } export class CreateApiKeyValidationError extends Error { diff --git a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts index 57e9ef0aed1ff..fa507df17661f 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts @@ -41,7 +41,7 @@ describe('UiamAPIKeys', () => { mockUiam = { getAuthenticationHeaders: jest.fn(), - getUserProfileGrant: jest.fn(), + getClientAuthentication: jest.fn(), getEsClientAuthenticationHeader: jest.fn().mockReturnValue({ 'x-client-authentication': 'shared-secret', }), @@ -327,32 +327,6 @@ describe('UiamAPIKeys', () => { }); }); - describe('isUiamCredential()', () => { - it('returns true when credentials start with UIAM prefix', () => { - const authorization = new HTTPAuthorizationHeader('ApiKey', 'essu_credential_123'); - - const result = UiamAPIKeys.isUiamCredential(authorization); - - expect(result).toBe(true); - }); - - it('returns false when credentials do not start with UIAM prefix', () => { - const authorization = new HTTPAuthorizationHeader('ApiKey', 'regular_credential_123'); - - const result = UiamAPIKeys.isUiamCredential(authorization); - - expect(result).toBe(false); - }); - - it('returns false when credentials are empty', () => { - const authorization = new HTTPAuthorizationHeader('ApiKey', ''); - - const result = UiamAPIKeys.isUiamCredential(authorization); - - expect(result).toBe(false); - }); - }); - describe('getAuthorizationHeader()', () => { it('extracts authorization header from request', () => { const request = httpServerMock.createKibanaRequest({ diff --git a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.ts b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.ts index ddd620d576986..3c8f47583ffd8 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.ts @@ -17,10 +17,9 @@ import type { import type { SecurityLicense } from '../../../../common'; import { getDetailedErrorMessage } from '../../../errors'; import type { UiamServicePublic } from '../../../uiam'; +import { isUiamCredential } from '../../../uiam'; import { HTTPAuthorizationHeader } from '../../http_authentication'; -const UIAM_CREDENTIALS_PREFIX = 'essu_'; - /** * Options required to construct a UiamAPIKeys instance. */ @@ -72,7 +71,7 @@ export class UiamAPIKeys implements UiamAPIKeysType { let result: GrantAPIKeyResult; // Provided credential must be a UIAM credential with appropriate prefix - if (!UiamAPIKeys.isUiamCredential(authorization)) { + if (!isUiamCredential(authorization)) { const nonUiamCredentialError = 'Cannot grant API key: provided credential is not compatible with UIAM'; this.logger.error(nonUiamCredentialError); @@ -118,7 +117,7 @@ export class UiamAPIKeys implements UiamAPIKeysType { this.logger.debug(`Trying to invalidate API key ${id}`); - if (!UiamAPIKeys.isUiamCredential(authorization)) { + if (!isUiamCredential(authorization)) { const uiamCredentialError = 'Cannot invalidate API key: not a UIAM API key'; this.logger.error(uiamCredentialError); throw new Error(uiamCredentialError); @@ -167,23 +166,11 @@ export class UiamAPIKeys implements UiamAPIKeysType { return this.clusterClient.asScoped({ headers: { authorization: authorization.toString(), - ...(UiamAPIKeys.isUiamCredential(authorization) - ? this.uiam.getEsClientAuthenticationHeader() - : {}), + ...(isUiamCredential(authorization) ? this.uiam.getEsClientAuthenticationHeader() : {}), }, }); } - /** - * Checks if the given authorization credentials are UIAM credentials. - * - * @param authorization The HTTP authorization header to check. - * @returns True if the credentials start with UIAM_CREDENTIALS_PREFIX, false otherwise. - */ - static isUiamCredential(authorization: HTTPAuthorizationHeader) { - return authorization.credentials.startsWith(UIAM_CREDENTIALS_PREFIX); - } - /** * Extracts and returns the authorization header from the request. * diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts b/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts index 9c057edb32ecd..cb2d3f656b4d5 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts @@ -360,6 +360,7 @@ export class AuthenticationService { applicationName, kibanaFeatures, buildFlavor, + uiam, }); const uiamAPIKeys = uiam diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts index b22007c34c287..a7cb2569c98ba 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts @@ -1753,10 +1753,9 @@ describe('SAMLAuthenticationProvider', () => { authentication: mockUser, in_response_to: mockSAMLSet1.requestId, }); - mockOptions.uiam?.getUserProfileGrant.mockReturnValue({ - type: 'uiamAccessToken', - accessToken: 'essu_dev_some-token', - sharedSecret: 'some-secret', + mockOptions.uiam?.getClientAuthentication.mockReturnValue({ + scheme: 'SharedSecret', + value: 'some-secret', }); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -1784,7 +1783,7 @@ describe('SAMLAuthenticationProvider', () => { userProfileGrant: { type: 'uiamAccessToken', accessToken: 'essu_dev_some-token', - sharedSecret: 'some-secret', + clientAuthentication: { scheme: 'SharedSecret', value: 'some-secret' }, }, state: { accessToken: 'essu_dev_some-token', @@ -1795,8 +1794,7 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.uiam?.getUserProfileGrant).toHaveBeenCalledTimes(1); - expect(mockOptions.uiam?.getUserProfileGrant).toHaveBeenCalledWith('essu_dev_some-token'); + expect(mockOptions.uiam?.getClientAuthentication).toHaveBeenCalledTimes(1); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ method: 'POST', path: '/_security/saml/authenticate', @@ -1913,19 +1911,18 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'essu_dev_new-refresh-token', }); - mockOptions.uiam?.getUserProfileGrant.mockReturnValue({ - accessToken: 'essu_dev_new-access-token', - sharedSecret: 'some-secret', - type: 'uiamAccessToken', + mockOptions.uiam?.getClientAuthentication.mockReturnValue({ + scheme: 'SharedSecret', + value: 'some-secret', }); await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization: 'Bearer essu_dev_new-access-token' }, userProfileGrant: { - accessToken: 'essu_dev_new-access-token', - sharedSecret: 'some-secret', type: 'uiamAccessToken', + accessToken: 'essu_dev_new-access-token', + clientAuthentication: { scheme: 'SharedSecret', value: 'some-secret' }, }, state: { accessToken: 'essu_dev_new-access-token', @@ -2025,7 +2022,7 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.uiam?.getUserProfileGrant).not.toHaveBeenCalled(); + expect(mockOptions.uiam?.getClientAuthentication).not.toHaveBeenCalled(); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ method: 'POST', path: '/_security/saml/authenticate', diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts index 3df1fa822b2e9..2eca72c97d24d 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts @@ -21,6 +21,7 @@ import { import type { AuthenticationInfo } from '../../elasticsearch'; import { getDetailedErrorMessage, InvalidGrantError } from '../../errors'; import type { UiamServicePublic } from '../../uiam'; +import { isUiamCredential } from '../../uiam/utils'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -511,7 +512,11 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { { user: this.authenticationInfoToAuthenticatedUser(result.authentication), userProfileGrant: this.isUiamToken(result.access_token) - ? this.options.uiam.getUserProfileGrant(result.access_token) + ? { + type: 'uiamAccessToken', + accessToken: result.access_token, + clientAuthentication: this.options.uiam.getClientAuthentication(), + } : { type: 'accessToken', accessToken: result.access_token }, state: { accessToken: result.access_token, @@ -708,7 +713,11 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), }, ...(this.isUiamToken(accessToken) && { - userProfileGrant: this.options.uiam.getUserProfileGrant(accessToken), + userProfileGrant: { + type: 'uiamAccessToken', + accessToken, + clientAuthentication: this.options.uiam.getClientAuthentication(), + }, }), state: { accessToken, refreshToken, realm: this.realm || state.realm }, } @@ -897,7 +906,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param token ES native or UIAM access or refresh token. */ private isUiamToken(token?: string): this is { options: { uiam: UiamServicePublic } } { - const isUiamToken = !!token?.startsWith('essu_'); + const isUiamToken = !!token && isUiamCredential(token); if (isUiamToken && !this.useUiam) { this.logger.error('Detected UIAM token, but the provider is not configured to use UIAM.'); } else if (!isUiamToken && this.useUiam) { diff --git a/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.test.ts b/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.test.ts index 3317fff03bd64..c4b6fb3296437 100644 --- a/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.test.ts @@ -75,6 +75,7 @@ it(`#setup returns exposed services`, () => { const authorizationService = new AuthorizationService(); const getClusterClient = () => Promise.resolve(mockClusterClient); + const getUiamService = jest.fn(); const authz = authorizationService.setup({ http: mockCoreSetup.http, capabilities: mockCoreSetup.capabilities, @@ -85,6 +86,7 @@ it(`#setup returns exposed services`, () => { packageVersion: 'some-version', features: mockFeaturesSetup, getSpacesService: mockGetSpacesService, + getUiamService, getCurrentUser: jest.fn(), customBranding: mockCoreSetup.customBranding, }); @@ -95,6 +97,7 @@ it(`#setup returns exposed services`, () => { expect(checkPrivilegesFactory).toHaveBeenCalledWith( authz.actions, getClusterClient, + getUiamService, authz.applicationName ); @@ -151,6 +154,7 @@ describe('#start', () => { getSpacesService: jest .fn() .mockReturnValue({ getSpaceId: jest.fn(), namespaceToSpaceId: jest.fn() }), + getUiamService: jest.fn(), getCurrentUser: jest.fn(), customBranding: mockCoreSetup.customBranding, }); @@ -223,6 +227,7 @@ it('#stop unsubscribes from license and ES updates.', async () => { getSpacesService: jest .fn() .mockReturnValue({ getSpaceId: jest.fn(), namespaceToSpaceId: jest.fn() }), + getUiamService: jest.fn(), getCurrentUser: jest.fn(), customBranding: mockCoreSetup.customBranding, }); diff --git a/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.ts b/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.ts index 133deb343805b..59035a3a21fab 100644 --- a/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.ts +++ b/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.ts @@ -52,6 +52,7 @@ import { canRedirectRequest } from '../authentication'; import type { OnlineStatusRetryScheduler } from '../elasticsearch'; import { createRedirectHtmlPage } from '../lib/html_page_utils'; import type { SpacesService } from '../plugin'; +import type { UiamServicePublic } from '../uiam'; export { Actions } from '@kbn/security-authorization-core'; @@ -66,6 +67,7 @@ interface AuthorizationServiceSetupParams { kibanaIndexName: string; getSpacesService(): SpacesService | undefined; + getUiamService(): UiamServicePublic | undefined; getCurrentUser(request: KibanaRequest): AuthenticatedUser | null; @@ -105,6 +107,7 @@ export class AuthorizationService { features, kibanaIndexName, getSpacesService, + getUiamService, getCurrentUser, customBranding, }: AuthorizationServiceSetupParams): AuthorizationServiceSetupInternal { @@ -118,6 +121,7 @@ export class AuthorizationService { const { checkPrivilegesWithRequest, checkUserProfilesPrivileges } = checkPrivilegesFactory( actions, getClusterClient, + getUiamService, this.applicationName ); diff --git a/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.test.ts b/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.test.ts index 49338c84317ca..4354f8393a4e6 100644 --- a/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.test.ts @@ -47,6 +47,7 @@ describe('#checkPrivilegesWithRequest.atSpace', () => { const { checkPrivilegesWithRequest } = checkPrivilegesFactory( mockActions, () => Promise.resolve(mockClusterClient), + () => undefined, application ); const request = httpServerMock.createKibanaRequest(); @@ -851,6 +852,7 @@ describe('#checkPrivilegesWithRequest.atSpace', () => { const { checkPrivilegesWithRequest } = checkPrivilegesFactory( mockActions, () => Promise.resolve(mockClusterClient), + () => undefined, application ); const request = httpServerMock.createKibanaRequest(); @@ -886,6 +888,7 @@ describe('#checkPrivilegesWithRequest.atSpaces', () => { const { checkPrivilegesWithRequest } = checkPrivilegesFactory( mockActions, () => Promise.resolve(mockClusterClient), + () => undefined, application ); const request = httpServerMock.createKibanaRequest(); @@ -2021,6 +2024,7 @@ describe('#checkPrivilegesWithRequest.atSpaces', () => { const { checkPrivilegesWithRequest } = checkPrivilegesFactory( mockActions, () => Promise.resolve(mockClusterClient), + () => undefined, application ); const request = httpServerMock.createKibanaRequest(); @@ -2055,6 +2059,7 @@ describe('#checkPrivilegesWithRequest.globally', () => { const { checkPrivilegesWithRequest } = checkPrivilegesFactory( mockActions, () => Promise.resolve(mockClusterClient), + () => undefined, application ); const request = httpServerMock.createKibanaRequest(); @@ -2869,6 +2874,7 @@ describe('#checkPrivilegesWithRequest.globally', () => { const { checkPrivilegesWithRequest } = checkPrivilegesFactory( mockActions, () => Promise.resolve(mockClusterClient), + () => undefined, application ); const request = httpServerMock.createKibanaRequest(); @@ -2903,6 +2909,7 @@ describe('#checkUserProfilesPrivileges.atSpace', () => { const { checkUserProfilesPrivileges } = checkPrivilegesFactory( mockActions, () => Promise.resolve(mockClusterClient), + () => undefined, application ); const checkPrivileges = checkUserProfilesPrivileges(new Set(options.uids)); diff --git a/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.ts b/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.ts index ad1c4d1d406d8..6a0584e212c8c 100644 --- a/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.ts +++ b/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.ts @@ -24,6 +24,9 @@ import { GLOBAL_RESOURCE } from '@kbn/security-plugin-types-server'; import { ResourceSerializer } from './resource_serializer'; import { validateEsPrivilegeResponse } from './validate_es_response'; +import { HTTPAuthorizationHeader } from '..'; +import type { UiamServicePublic } from '../uiam'; +import { isUiamCredential } from '../uiam'; interface CheckPrivilegesActions { login: string; @@ -32,6 +35,7 @@ interface CheckPrivilegesActions { export function checkPrivilegesFactory( actions: CheckPrivilegesActions, getClusterClient: () => Promise, + getUiamService: () => UiamServicePublic | undefined, applicationName: string ) { const createApplicationPrivilegesCheck = ( @@ -53,6 +57,27 @@ export function checkPrivilegesFactory( }; }; + async function getScopedClusterClient(request: KibanaRequest) { + const clusterClient = await getClusterClient(); + + // If we're not in UIAM mode or if the request is not a fake request, use request scope directly. + const uiam = getUiamService(); + if (!uiam || request.isFakeRequest === false) { + return clusterClient.asScoped(request); + } + + // In UIAM mode and for fake requests, it's still possible that the request is authenticated with non-UIAM credentials. + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (!authorizationHeader || !isUiamCredential(authorizationHeader)) { + return clusterClient.asScoped(request); + } + + // For UIAM credentials, we need to add the UIAM authentication header to the scoped client. + return clusterClient.asScoped({ + headers: { ...request.headers, ...uiam.getEsClientAuthenticationHeader() }, + }); + } + function checkUserProfilesPrivileges(userProfileUids: Set): CheckUserProfilesPrivileges { const checkPrivilegesAtResources = async ( resources: string[], @@ -107,8 +132,8 @@ export function checkPrivilegesFactory( { requireLoginAction } ); - const clusterClient = await getClusterClient(); - const body = await clusterClient.asScoped(request).asCurrentUser.security.hasPrivileges({ + const clusterClient = await getScopedClusterClient(request); + const body = await clusterClient.asCurrentUser.security.hasPrivileges({ cluster: privileges.elasticsearch?.cluster as estypes.SecurityClusterPrivilege[], index: Object.entries(privileges.elasticsearch?.index ?? {}).map( ([name, indexPrivileges]) => ({ diff --git a/x-pack/platform/plugins/shared/security/server/plugin.ts b/x-pack/platform/plugins/shared/security/server/plugin.ts index 71f40340c103d..8e3d7f3c2a899 100644 --- a/x-pack/platform/plugins/shared/security/server/plugin.ts +++ b/x-pack/platform/plugins/shared/security/server/plugin.ts @@ -56,6 +56,7 @@ import { setupSavedObjects } from './saved_objects'; import type { Session } from './session_management'; import { SessionManagementService } from './session_management'; import { setupSpacesClient } from './spaces'; +import type { UiamServicePublic } from './uiam'; import { UiamService } from './uiam'; import { registerSecurityUsageCollector } from './usage_collector'; import { UserProfileService } from './user_profile'; @@ -173,6 +174,8 @@ export class SecurityPlugin private readonly userProfileService: UserProfileService; private userProfileStart?: UserProfileServiceStartInternal; + private uiamService?: UiamServicePublic; + private readonly getUserProfileService = () => { if (!this.userProfileStart) { throw new Error(`userProfileStart is not registered!`); @@ -282,6 +285,7 @@ export class SecurityPlugin kibanaIndexName, packageVersion: this.initializerContext.env.packageInfo.version, getSpacesService: () => spaces?.spacesService, + getUiamService: () => this.uiamService, features, getCurrentUser, customBranding: core.customBranding, @@ -400,6 +404,10 @@ export class SecurityPlugin : undefined; const config = this.getConfig(); + this.uiamService = config.uiam?.enabled + ? new UiamService(this.logger.get('uiam'), config.uiam) + : undefined; + this.authenticationStart = this.authenticationService.start({ audit: this.auditSetup!, clusterClient, @@ -409,9 +417,7 @@ export class SecurityPlugin http: core.http, loggers: this.initializerContext.logger, session, - uiam: config.uiam?.enabled - ? new UiamService(this.logger.get('uiam'), config.uiam) - : undefined, + uiam: this.uiamService, applicationName: this.authorizationSetup!.applicationName, kibanaFeatures: features.getKibanaFeatures(), isElasticCloudDeployment: () => cloud?.isCloudEnabled === true, diff --git a/x-pack/platform/plugins/shared/security/server/uiam/index.ts b/x-pack/platform/plugins/shared/security/server/uiam/index.ts index b542efe912791..35707eb14c550 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/index.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/index.ts @@ -6,3 +6,4 @@ */ export { UiamService, type UiamServicePublic } from './uiam_service'; +export { isUiamCredential } from './utils'; diff --git a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts index 4e68ba5dcdeb7..fc6311d7b3e49 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts @@ -14,7 +14,7 @@ export const uiamServiceMock = { authorization: `Bearer ${accessToken}`, [ES_CLIENT_AUTHENTICATION_HEADER]: 'some-shared-secret', })), - getUserProfileGrant: jest.fn(), + getClientAuthentication: jest.fn(), getEsClientAuthenticationHeader: jest.fn().mockReturnValue({ [ES_CLIENT_AUTHENTICATION_HEADER]: 'some-shared-secret', }), diff --git a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts index 3b6d1fb5c3297..6fa1899b1a0ca 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts @@ -200,12 +200,11 @@ describe('UiamService', () => { }); }); - describe('#getUserProfileGrant', () => { - it('includes shared secret in a profile grant', () => { - expect(uiamService.getUserProfileGrant('some-token')).toEqual({ - type: 'uiamAccessToken', - accessToken: 'some-token', - sharedSecret: 'secret', + describe('#getClientAuthentication', () => { + it('includes shared secret in client authentication', () => { + expect(uiamService.getClientAuthentication()).toEqual({ + scheme: 'SharedSecret', + value: 'secret', }); }); }); diff --git a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts index 472a06dfdcfef..1d827b0c256c1 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts @@ -10,13 +10,15 @@ import { readFileSync } from 'fs'; import { Agent } from 'undici'; import type { Logger } from '@kbn/core/server'; -import type { GrantUiamAPIKeyParams } from '@kbn/security-plugin-types-server'; +import type { + ClientAuthentication, + GrantUiamAPIKeyParams, +} from '@kbn/security-plugin-types-server'; import { HTTPAuthorizationHeader } from '..'; import { ES_CLIENT_AUTHENTICATION_HEADER } from '../../common/constants'; import type { UiamConfigType } from '../config'; import { getDetailedErrorMessage } from '../errors'; -import type { UserProfileGrant } from '../user_profile'; /** * Represents the request body for granting an API key via UIAM. @@ -63,10 +65,10 @@ export interface UiamServicePublic { getAuthenticationHeaders(accessToken: string): Record; /** - * Creates a user profile grant based on the provided access token. - * @param accessToken UIAM session access token. + * Returns the Elasticsearch client authentication information with the shared secret value. This is to be used with + * `client_authentication` option in Elasticsearch client. */ - getUserProfileGrant(accessToken: string): UserProfileGrant; + getClientAuthentication(): ClientAuthentication; /** * Returns the Elasticsearch client authentication header (`x-client-authentication`) with the shared secret value. @@ -148,23 +150,17 @@ export class UiamService implements UiamServicePublic { } /** - * See {@link UiamServicePublic.getUserProfileGrant}. + * See {@link UiamServicePublic.getClientAuthentication}. */ - getUserProfileGrant(accessToken: string): UserProfileGrant { - return { - type: 'uiamAccessToken' as const, - accessToken, - sharedSecret: this.#config.sharedSecret, - }; + getClientAuthentication(): ClientAuthentication { + return { scheme: 'SharedSecret', value: this.#config.sharedSecret }; } /** * See {@link UiamServicePublic.getEsClientAuthenticationHeader}. */ getEsClientAuthenticationHeader(): Record { - return { - [ES_CLIENT_AUTHENTICATION_HEADER]: this.#config.sharedSecret, - }; + return { [ES_CLIENT_AUTHENTICATION_HEADER]: this.getClientAuthentication().value }; } /** diff --git a/x-pack/platform/plugins/shared/security/server/uiam/utils.test.ts b/x-pack/platform/plugins/shared/security/server/uiam/utils.test.ts new file mode 100644 index 0000000000000..d5bf0d095682b --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/uiam/utils.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { isUiamCredential } from './utils'; +import { HTTPAuthorizationHeader } from '../authentication'; + +describe('#isUiamCredential()', () => { + it('returns `true` when credential is a valid UIAM credential', () => { + for (const credential of ['essu_credential_123', 'essu_dev_credential_123']) { + expect(isUiamCredential(new HTTPAuthorizationHeader('ApiKey', credential))).toBe(true); + expect(isUiamCredential(credential)).toBe(true); + } + }); + + it('returns `false` when credential is NOT a valid UIAM credential', () => { + for (const credential of [ + 'ess_credential_123', + 'regular_credential_123', + '_essu_credential_123', + '', + ]) { + expect(isUiamCredential(new HTTPAuthorizationHeader('ApiKey', credential))).toBe(false); + expect(isUiamCredential(credential)).toBe(false); + } + }); +}); diff --git a/x-pack/platform/plugins/shared/security/server/uiam/utils.ts b/x-pack/platform/plugins/shared/security/server/uiam/utils.ts new file mode 100644 index 0000000000000..c70294935aa89 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/uiam/utils.ts @@ -0,0 +1,22 @@ +/* + * 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 { HTTPAuthorizationHeader } from '../authentication/http_authentication'; + +const UIAM_CREDENTIALS_PREFIX = 'essu_'; + +/** + * Checks if the given authorization credentials are UIAM credentials. + * + * @param credential The HTTP authorization header or access token to check. + * @returns True if the credentials start with UIAM_CREDENTIALS_PREFIX, false otherwise. + */ +export function isUiamCredential(credential: HTTPAuthorizationHeader | string) { + return ( + credential instanceof HTTPAuthorizationHeader ? credential.credentials : credential + ).startsWith(UIAM_CREDENTIALS_PREFIX); +} diff --git a/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_grant.ts b/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_grant.ts index 1d28df0d9e7a6..db591f5e60121 100644 --- a/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_grant.ts +++ b/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_grant.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { ClientAuthentication } from '@kbn/security-plugin-types-server'; + /** * Represents a union of all possible user profile grant types. */ @@ -36,5 +38,5 @@ export interface AccessTokenUserProfileGrant { export interface UiamAccessTokenUserProfileGrant { readonly type: 'uiamAccessToken'; readonly accessToken: string; - readonly sharedSecret: string; + readonly clientAuthentication: ClientAuthentication; } diff --git a/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.test.ts b/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.test.ts index 9e8442187c7e7..b39c82c253f42 100644 --- a/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.test.ts @@ -393,7 +393,7 @@ describe('UserProfileService', () => { startContract.activate({ type: 'uiamAccessToken', accessToken: 'some-token', - sharedSecret: 'some-shared-secret', + clientAuthentication: { scheme: 'SharedSecret', value: 'some-shared-secret' }, }) ).resolves.toMatchInlineSnapshot(` Object { diff --git a/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.ts b/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.ts index 6db92d339a341..926140f0d21f2 100644 --- a/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.ts +++ b/x-pack/platform/plugins/shared/security/server/user_profile/user_profile_service.ts @@ -136,7 +136,7 @@ export class UserProfileService { grant_type: 'access_token', access_token: grant.accessToken, ...(grant.type === 'uiamAccessToken' - ? { client_authentication: { scheme: 'SharedSecret', value: grant.sharedSecret } } + ? { client_authentication: grant.clientAuthentication } : {}), }; From 93f50f44835d0aa1cd5208c73da2fd0eb28f549c Mon Sep 17 00:00:00 2001 From: Kurt Greiner Date: Thu, 5 Feb 2026 08:56:26 -0500 Subject: [PATCH 2/2] Refactor getScoped client into lib directory and adding tests for es/uiam auth headers --- .../authentication/api_keys/api_keys.test.ts | 162 ++++++++++++++++++ .../authentication/api_keys/api_keys.ts | 42 ++--- .../authorization/check_privileges.test.ts | 155 +++++++++++++++++ .../server/authorization/check_privileges.ts | 21 +-- .../server/lib/get_scoped_client.test.ts | 125 ++++++++++++++ .../security/server/lib/get_scoped_client.ts | 42 +++++ .../shared/security/server/lib/index.ts | 1 + 7 files changed, 506 insertions(+), 42 deletions(-) create mode 100644 x-pack/platform/plugins/shared/security/server/lib/get_scoped_client.test.ts create mode 100644 x-pack/platform/plugins/shared/security/server/lib/get_scoped_client.ts diff --git a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.test.ts index ad39c955fdf53..154d9952e56b4 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.test.ts @@ -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'); @@ -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()', () => { diff --git a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.ts b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.ts index 31d58e7716c6b..b85cd219805fc 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/api_keys.ts @@ -26,7 +26,11 @@ 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 { @@ -162,7 +166,7 @@ export class APIKeys implements NativeAPIKeysType { return null; } const { type, expiration, name, metadata } = createParams; - const scopedClusterClient = this.getScopedClient(request); + const scopedClusterClient = getScopedClient(request, this.clusterClient, this.uiam); this.logger.debug('Trying to create an API key'); @@ -217,7 +221,7 @@ export class APIKeys implements NativeAPIKeysType { } const { type, id, metadata } = updateParams; - const scopedClusterClient = this.getScopedClient(request); + const scopedClusterClient = getScopedClient(request, this.clusterClient, this.uiam); this.logger.debug('Trying to edit an API key'); @@ -281,6 +285,7 @@ export class APIKeys implements NativeAPIKeysType { // 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 { @@ -288,6 +293,7 @@ export class APIKeys implements NativeAPIKeysType { request, ELASTICSEARCH_CLIENT_AUTHENTICATION_HEADER ); + if (clientAuthorizationHeader) { clientAuthentication = { scheme: clientAuthorizationHeader.scheme, @@ -338,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.getScopedClient(request).asCurrentUser.security.invalidateApiKey({ + result = await getScopedClient( + request, + this.clusterClient, + this.uiam + ).asCurrentUser.security.invalidateApiKey({ ids: params.ids, }); this.logger.debug( @@ -398,7 +408,11 @@ export class APIKeys implements NativeAPIKeysType { this.logger.debug(`Trying to validate an API key`); try { - await this.getScopedClient(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) { @@ -491,24 +505,6 @@ export class APIKeys implements NativeAPIKeysType { return roleDescriptors; } - - private getScopedClient(request: KibanaRequest) { - // If we're not in UIAM mode or if the request is not a fake request, use request scope directly. - if (!this.uiam || request.isFakeRequest === false) { - return this.clusterClient.asScoped(request); - } - - // In UIAM mode and for fake requests, it's still possible that the request is authenticated with non-UIAM credentials. - const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); - if (!authorizationHeader || !isUiamCredential(authorizationHeader)) { - return this.clusterClient.asScoped(request); - } - - // For UIAM credentials, we need to add the UIAM authentication header to the scoped client. - return this.clusterClient.asScoped({ - headers: { ...request.headers, ...this.uiam.getEsClientAuthenticationHeader() }, - }); - } } export class CreateApiKeyValidationError extends Error { diff --git a/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.test.ts b/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.test.ts index 4354f8393a4e6..c91bd6c7b27d3 100644 --- a/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.test.ts @@ -12,6 +12,7 @@ import { GLOBAL_RESOURCE } from '@kbn/security-plugin-types-server'; import type { HasPrivilegesResponse } from '@kbn/security-plugin-types-server'; import { checkPrivilegesFactory } from './check_privileges'; +import { uiamServiceMock } from '../uiam/uiam_service.mock'; const application = 'kibana-our_application'; @@ -2894,6 +2895,160 @@ describe('#checkPrivilegesWithRequest.globally', () => { }); }); +describe('#checkPrivilegesWithRequest with UIAM', () => { + it('scopes client with UIAM headers when using fake request with UIAM credentials', async () => { + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.asCurrentUser.security.hasPrivileges.mockResponse({ + has_all_requested: true, + username: 'uiam-user', + cluster: {}, + index: {}, + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + }, + }, + }, + } as any); + + const mockUiam = uiamServiceMock.create(); + const { checkPrivilegesWithRequest } = checkPrivilegesFactory( + mockActions, + () => Promise.resolve(mockClusterClient), + () => mockUiam, + application + ); + + const request = httpServerMock.createFakeKibanaRequest({ + headers: { authorization: 'ApiKey essu_uiam_credential_123' }, + }); + const checkPrivileges = checkPrivilegesWithRequest(request); + await checkPrivileges.atSpace('space_1', {}); + + // Should scope with UIAM headers added + expect(mockClusterClient.asScoped).toHaveBeenCalledWith({ + headers: { + authorization: 'ApiKey essu_uiam_credential_123', + 'x-client-authentication': 'some-shared-secret', + }, + }); + expect(mockUiam.getEsClientAuthenticationHeader).toHaveBeenCalled(); + }); + + it('scopes client with request directly when using real request even with UIAM credentials', async () => { + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.asCurrentUser.security.hasPrivileges.mockResponse({ + has_all_requested: true, + username: 'uiam-user', + cluster: {}, + index: {}, + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + }, + }, + }, + } as any); + + const mockUiam = uiamServiceMock.create(); + const { checkPrivilegesWithRequest } = checkPrivilegesFactory( + mockActions, + () => Promise.resolve(mockClusterClient), + () => mockUiam, + application + ); + + // Real request (not fake) - should use request directly even with UIAM-like credentials + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'ApiKey essu_uiam_credential_123' }, + }); + const checkPrivileges = checkPrivilegesWithRequest(request); + await checkPrivileges.atSpace('space_1', {}); + + // Should scope with request directly (not add UIAM headers) + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockUiam.getEsClientAuthenticationHeader).not.toHaveBeenCalled(); + }); + + it('scopes client with request directly when using fake request with non-UIAM credentials', async () => { + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.asCurrentUser.security.hasPrivileges.mockResponse({ + has_all_requested: true, + username: 'regular-user', + cluster: {}, + index: {}, + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + }, + }, + }, + } as any); + + const mockUiam = uiamServiceMock.create(); + const { checkPrivilegesWithRequest } = checkPrivilegesFactory( + mockActions, + () => Promise.resolve(mockClusterClient), + () => mockUiam, + application + ); + + const request = httpServerMock.createFakeKibanaRequest({ + headers: { authorization: 'ApiKey regular_api_key_123' }, + }); + const checkPrivileges = checkPrivilegesWithRequest(request); + await checkPrivileges.atSpace('space_1', {}); + + // Should scope with request directly (non-UIAM credentials) + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockUiam.getEsClientAuthenticationHeader).not.toHaveBeenCalled(); + }); + + it('scopes client with request directly when UIAM is not configured', async () => { + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.asCurrentUser.security.hasPrivileges.mockResponse({ + has_all_requested: true, + username: 'regular-user', + cluster: {}, + index: {}, + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + }, + }, + }, + } as any); + + const { checkPrivilegesWithRequest } = checkPrivilegesFactory( + mockActions, + () => Promise.resolve(mockClusterClient), + () => undefined, // No UIAM service + application + ); + + const request = httpServerMock.createFakeKibanaRequest({ + headers: { authorization: 'ApiKey essu_uiam_credential_123' }, + }); + const checkPrivileges = checkPrivilegesWithRequest(request); + await checkPrivileges.atSpace('space_1', {}); + + // Should scope with request directly (no UIAM service configured) + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + }); +}); + describe('#checkUserProfilesPrivileges.atSpace', () => { const checkPrivilegesAtSpaceTest = async (options: { spaceId: string; diff --git a/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.ts b/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.ts index 6a0584e212c8c..a76534f4dcf3b 100644 --- a/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.ts +++ b/x-pack/platform/plugins/shared/security/server/authorization/check_privileges.ts @@ -24,9 +24,8 @@ import { GLOBAL_RESOURCE } from '@kbn/security-plugin-types-server'; import { ResourceSerializer } from './resource_serializer'; import { validateEsPrivilegeResponse } from './validate_es_response'; -import { HTTPAuthorizationHeader } from '..'; +import { getScopedClient } from '../lib'; import type { UiamServicePublic } from '../uiam'; -import { isUiamCredential } from '../uiam'; interface CheckPrivilegesActions { login: string; @@ -59,23 +58,7 @@ export function checkPrivilegesFactory( async function getScopedClusterClient(request: KibanaRequest) { const clusterClient = await getClusterClient(); - - // If we're not in UIAM mode or if the request is not a fake request, use request scope directly. - const uiam = getUiamService(); - if (!uiam || request.isFakeRequest === false) { - return clusterClient.asScoped(request); - } - - // In UIAM mode and for fake requests, it's still possible that the request is authenticated with non-UIAM credentials. - const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); - if (!authorizationHeader || !isUiamCredential(authorizationHeader)) { - return clusterClient.asScoped(request); - } - - // For UIAM credentials, we need to add the UIAM authentication header to the scoped client. - return clusterClient.asScoped({ - headers: { ...request.headers, ...uiam.getEsClientAuthenticationHeader() }, - }); + return getScopedClient(request, clusterClient, getUiamService()); } function checkUserProfilesPrivileges(userProfileUids: Set): CheckUserProfilesPrivileges { diff --git a/x-pack/platform/plugins/shared/security/server/lib/get_scoped_client.test.ts b/x-pack/platform/plugins/shared/security/server/lib/get_scoped_client.test.ts new file mode 100644 index 0000000000000..ea06ffa6ebfad --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/lib/get_scoped_client.test.ts @@ -0,0 +1,125 @@ +/* + * 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 { elasticsearchServiceMock, httpServerMock } from '@kbn/core/server/mocks'; + +import { getScopedClient } from './get_scoped_client'; +import { uiamServiceMock } from '../uiam/uiam_service.mock'; + +describe('getScopedClient', () => { + let mockClusterClient: ReturnType; + let mockScopedClusterClient: ReturnType< + typeof elasticsearchServiceMock.createScopedClusterClient + >; + + beforeEach(() => { + mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + }); + + describe('when uiam is not provided', () => { + it('returns a scoped client using a real request directly', () => { + const request = httpServerMock.createKibanaRequest(); + + const result = getScopedClient(request, mockClusterClient); + + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(result).toBe(mockScopedClusterClient); + }); + + it('returns a scoped client using a fake request directly', () => { + const request = httpServerMock.createFakeKibanaRequest({}); + + const result = getScopedClient(request, mockClusterClient); + + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(result).toBe(mockScopedClusterClient); + }); + }); + + describe('when uiam is provided', () => { + describe('with a real request', () => { + it('returns a scoped client using the request directly', () => { + const request = httpServerMock.createKibanaRequest(); + const mockUiam = uiamServiceMock.create(); + + const result = getScopedClient(request, mockClusterClient, mockUiam); + + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockUiam.getEsClientAuthenticationHeader).not.toHaveBeenCalled(); + expect(result).toBe(mockScopedClusterClient); + }); + + it('returns a scoped client using the request directly even with UIAM credentials', () => { + const request = httpServerMock.createKibanaRequest({ + headers: { + authorization: 'ApiKey essu_credential_123', + }, + }); + const mockUiam = uiamServiceMock.create(); + + const result = getScopedClient(request, mockClusterClient, mockUiam); + + // Real requests always use the request directly, even with UIAM credentials + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockUiam.getEsClientAuthenticationHeader).not.toHaveBeenCalled(); + expect(result).toBe(mockScopedClusterClient); + }); + }); + + describe('with a fake request', () => { + it('returns a scoped client using the request directly when fake request has no authorization header', () => { + const request = httpServerMock.createFakeKibanaRequest({ + headers: {}, + }); + const mockUiam = uiamServiceMock.create(); + + const result = getScopedClient(request, mockClusterClient, mockUiam); + + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockUiam.getEsClientAuthenticationHeader).not.toHaveBeenCalled(); + expect(result).toBe(mockScopedClusterClient); + }); + + it('returns a scoped client using the request directly when fake request has non-UIAM credentials', () => { + const request = httpServerMock.createFakeKibanaRequest({ + headers: { + authorization: 'ApiKey regular_credential_123', + }, + }); + const mockUiam = uiamServiceMock.create(); + + const result = getScopedClient(request, mockClusterClient, mockUiam); + + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockUiam.getEsClientAuthenticationHeader).not.toHaveBeenCalled(); + expect(result).toBe(mockScopedClusterClient); + }); + + it('returns a scoped client with UIAM authentication header when fake request has UIAM credentials', () => { + const request = httpServerMock.createFakeKibanaRequest({ + headers: { + authorization: 'ApiKey essu_credential_123', + }, + }); + const mockUiam = uiamServiceMock.create(); + + const result = getScopedClient(request, mockClusterClient, mockUiam); + + expect(mockUiam.getEsClientAuthenticationHeader).toHaveBeenCalled(); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith({ + headers: { + authorization: 'ApiKey essu_credential_123', + ...mockUiam.getEsClientAuthenticationHeader(), + }, + }); + expect(result).toBe(mockScopedClusterClient); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/security/server/lib/get_scoped_client.ts b/x-pack/platform/plugins/shared/security/server/lib/get_scoped_client.ts new file mode 100644 index 0000000000000..cac74328a034a --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/lib/get_scoped_client.ts @@ -0,0 +1,42 @@ +/* + * 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 { IClusterClient, KibanaRequest } from '@kbn/core/server'; + +import { HTTPAuthorizationHeader } from '..'; +import { isUiamCredential, type UiamServicePublic } from '../uiam'; + +/** + * Gets a scoped Elasticsearch client for the given request, handling UIAM credentials appropriately. + * + * @param request Request instance. + * @param clusterClient The cluster client to scope. + * @param uiam Optional UIAM service for handling UIAM credentials. + * @returns A scoped Elasticsearch client. + */ +export function getScopedClient( + request: KibanaRequest, + clusterClient: IClusterClient, + uiam?: UiamServicePublic +) { + // If we're not in UIAM mode or if the request is not a fake request, use request scope directly. + if (!uiam || !request.isFakeRequest) { + return clusterClient.asScoped(request); + } + + // In UIAM mode and for fake requests, it's still possible that the request is authenticated with non-UIAM credentials. + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + + if (!authorizationHeader || !isUiamCredential(authorizationHeader)) { + return clusterClient.asScoped(request); + } + + // For UIAM credentials, we need to add the UIAM authentication header to the scoped client. + return clusterClient.asScoped({ + headers: { ...request.headers, ...uiam.getEsClientAuthenticationHeader() }, + }); +} diff --git a/x-pack/platform/plugins/shared/security/server/lib/index.ts b/x-pack/platform/plugins/shared/security/server/lib/index.ts index 496f60fdb098c..0c4a71c8aef66 100644 --- a/x-pack/platform/plugins/shared/security/server/lib/index.ts +++ b/x-pack/platform/plugins/shared/security/server/lib/index.ts @@ -10,3 +10,4 @@ export { transformPrivilegesToElasticsearchPrivileges, } from './role_utils'; export { flattenObject } from './flatten_object'; +export { getScopedClient } from './get_scoped_client';