diff --git a/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_api_keys.spec.ts b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_api_keys.spec.ts new file mode 100644 index 0000000000000..b36c082ccc3ea --- /dev/null +++ b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_api_keys.spec.ts @@ -0,0 +1,184 @@ +/* + * 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 { parse as parseCookie } from 'tough-cookie'; + +import { createSAMLResponse, MOCK_IDP_ATTRIBUTE_UIAM_ACCESS_TOKEN } from '@kbn/mock-idp-utils'; +import { apiTest, tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; + +import { COMMON_UNSAFE_HEADERS, extractAttributeValue } from '../fixtures'; + +// These tests cannot be run on MKI because we cannot obtain the raw UIAM tokens and spin up Mock IdP plugin. +apiTest.describe( + '[NON-MKI] UIAM API Keys grant and invalidate functions', + { tag: [...tags.serverless.security.complete] }, + () => { + let userSessionCookieFactory: () => Promise<[string, { accessToken: string }]>; + + apiTest.beforeAll(async ({ apiClient, kbnUrl, config: { organizationId, projectType } }) => { + userSessionCookieFactory = async () => { + const samlResponse = await createSAMLResponse({ + kibanaUrl: kbnUrl.get('/api/security/saml/callback'), + username: '1234567890', + email: 'elastic_admin@elastic.co', + roles: ['admin'], + serverless: { + uiamEnabled: true, + organizationId: organizationId!, + projectType: projectType!, + }, + }); + + const decodedSamlResponse = Buffer.from(samlResponse, 'base64').toString('utf-8'); + return [ + parseCookie( + ( + await apiClient.post('api/security/saml/callback', { + body: `SAMLResponse=${encodeURIComponent(samlResponse)}`, + }) + ).headers['set-cookie'][0] + )!.cookieString(), + { + accessToken: extractAttributeValue( + decodedSamlResponse, + MOCK_IDP_ATTRIBUTE_UIAM_ACCESS_TOKEN + ), + }, + ]; + }; + }); + + apiTest( + 'should be able to grant a UIAM API key with valid UIAM credentials', + async ({ apiClient }) => { + // 1. Log in to obtain a UIAM access token. + const [_, { accessToken }] = await userSessionCookieFactory(); + + // 2. Grant an API key using the UIAM access token via the test endpoint. + const responseUsingAccessToken = await apiClient.post( + 'test_endpoints/uiam/api_keys/_grant', + { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + name: 'test-uiam-api-key-from-token', + authcScheme: 'Bearer', + credential: accessToken, + }, + } + ); + + expect(responseUsingAccessToken).toHaveStatusCode(200); + expect(responseUsingAccessToken.body.id).toBeDefined(); + expect(responseUsingAccessToken.body.name).toBe('test-uiam-api-key-from-token'); + expect(responseUsingAccessToken.body.api_key).toBeDefined(); + expect(typeof responseUsingAccessToken.body.api_key).toBe('string'); + expect(typeof responseUsingAccessToken.body.id).toBe('string'); + + // 3. UIAM API Keys should be able to grant additional UIAM API keys. + const apiKey = responseUsingAccessToken.body.api_key; + + const responseUsingApiKey = await apiClient.post('test_endpoints/uiam/api_keys/_grant', { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + name: 'test-uiam-api-key-from-api-key', + authcScheme: 'ApiKey', + credential: apiKey, + }, + }); + + expect(responseUsingApiKey).toHaveStatusCode(200); + expect(responseUsingApiKey.body.id).toBeDefined(); + expect(typeof responseUsingApiKey.body.id).toBe('string'); + expect(responseUsingApiKey.body.name).toBe('test-uiam-api-key-from-api-key'); + expect(responseUsingApiKey.body.api_key).toBeDefined(); + expect(typeof responseUsingApiKey.body.api_key).toBe('string'); + } + ); + + apiTest( + 'should be able to invalidate a UIAM API key with valid UIAM credentials', + async ({ apiClient }) => { + // 1. Log in to obtain a UIAM access token. + const [_, { accessToken }] = await userSessionCookieFactory(); + + // 2. Grant an API key first. + const grantResponse = await apiClient.post('test_endpoints/uiam/api_keys/_grant', { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + name: 'test-uiam-api-key-to-invalidate', + authcScheme: 'Bearer', + credential: accessToken, + }, + }); + expect(grantResponse).toHaveStatusCode(200); + + const apiKeyId = grantResponse.body.id; + const apiKey = grantResponse.body.api_key; + + // 3. Invalidate the API key. + const invalidateResponse = await apiClient.post( + 'test_endpoints/uiam/api_keys/_invalidate', + { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + id: apiKeyId, + authcScheme: 'ApiKey', + credential: apiKey, + }, + } + ); + + expect(invalidateResponse).toHaveStatusCode(200); + expect(invalidateResponse.body).toStrictEqual( + expect.objectContaining({ + invalidated_api_keys: [apiKeyId], + error_count: 0, + }) + ); + } + ); + + apiTest('should reject grant request with non-UIAM credentials', async ({ apiClient }) => { + // Attempt to grant an API key using non-UIAM credentials. + const response = await apiClient.post('test_endpoints/uiam/api_keys/_grant', { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + name: 'test-invalid-api-key', + authcScheme: 'Bearer', + credential: 'some-invalid-token', + }, + }); + + expect(response).toHaveStatusCode(500); + expect(response.body.message).toBeDefined(); + expect(response.body.message).toContain('not compatible with UIAM'); + }); + + apiTest('should reject invalidate request with non-UIAM credentials', async ({ apiClient }) => { + // Attempt to invalidate an API key using non UIAM API Key. + const response = await apiClient.post('test_endpoints/uiam/api_keys/_invalidate', { + headers: { ...COMMON_UNSAFE_HEADERS }, + responseType: 'json', + body: { + id: 'some-api-key-id', + authcScheme: 'ApiKey', + credential: 'some-api-key-value:', + }, + }); + + expect(response).toHaveStatusCode(500); + expect(response.body.message).toBeDefined(); + expect(response.body.message).toContain('not a UIAM API key'); + }); + } +); diff --git a/x-pack/platform/test/security_functional/plugins/test_endpoints/moon.yml b/x-pack/platform/test/security_functional/plugins/test_endpoints/moon.yml index 0c1e7de69b397..b37336be6e35f 100644 --- a/x-pack/platform/test/security_functional/plugins/test_endpoints/moon.yml +++ b/x-pack/platform/test/security_functional/plugins/test_endpoints/moon.yml @@ -23,6 +23,8 @@ dependsOn: - '@kbn/task-manager-plugin' - '@kbn/config-schema' - '@kbn/security-plugin-types-server' + - '@kbn/core-http-server' + - '@kbn/core-http-server-utils' tags: - plugin - prod diff --git a/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts b/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts index 61ff48b406aba..96fd539f4aea9 100644 --- a/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts +++ b/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts @@ -9,6 +9,8 @@ import { type DiagnosticResult, errors } from '@elastic/elasticsearch'; import { schema } from '@kbn/config-schema'; import type { CoreSetup, CoreStart, PluginInitializerContext } from '@kbn/core/server'; +import type { FakeRawRequest, Headers } from '@kbn/core-http-server'; +import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; import { ROUTE_TAG_AUTH_FLOW } from '@kbn/security-plugin/server'; import { restApiKeySchema } from '@kbn/security-plugin-types-server'; import type { @@ -495,4 +497,122 @@ export function initRoutes( } } ); + + // UIAM API Key Grant Route + router.post( + { + path: '/test_endpoints/uiam/api_keys/_grant', + validate: { + body: schema.object({ + name: schema.string(), + expiration: schema.maybe(schema.string()), + authcScheme: schema.string(), + credential: schema.string(), + }), + }, + security: { + authc: { enabled: 'optional' }, + authz: { enabled: false, reason: 'Test endpoint for UIAM API key operations' }, + }, + }, + async (context, request, response) => { + try { + const { name, expiration, authcScheme, credential } = request.body; + const [{ security }] = await core.getStartServices(); + + if (!security.authc.apiKeys.uiam) { + return response.badRequest({ + body: { message: 'UIAM API keys service is not available' }, + }); + } + + // Create a new request with the provided authentication header + const requestHeaders: Headers = { + ...request.headers, + authorization: `${authcScheme} ${credential}`, + }; + const fakeRawRequest: FakeRawRequest = { + headers: requestHeaders, + path: request.url.pathname, + }; + const requestToUse = kibanaRequestFactory(fakeRawRequest); + + const result = await security.authc.apiKeys.uiam.grant(requestToUse, { + name, + expiration, + }); + + if (!result) { + return response.badRequest({ + body: { message: 'Failed to grant UIAM API key' }, + }); + } + + return response.ok({ body: result }); + } catch (err) { + logger.error(`Failed to grant UIAM API key: ${err}`, err); + return response.customError({ + statusCode: 500, + body: { message: err.message }, + }); + } + } + ); + + // UIAM API Key Invalidate Route + router.post( + { + path: '/test_endpoints/uiam/api_keys/_invalidate', + validate: { + body: schema.object({ + id: schema.string(), + authcScheme: schema.string(), + credential: schema.string(), + }), + }, + security: { + authc: { enabled: 'optional' }, + authz: { enabled: false, reason: 'Test endpoint for UIAM API key operations' }, + }, + }, + async (context, request, response) => { + try { + const { id, authcScheme, credential } = request.body; + const [{ security }] = await core.getStartServices(); + + if (!security.authc.apiKeys.uiam) { + return response.badRequest({ + body: { message: 'UIAM API keys service is not available' }, + }); + } + + // Create a new request with the provided authentication header + const requestHeaders: Headers = { + ...request.headers, + authorization: `${authcScheme} ${credential}`, + }; + const fakeRawRequest: FakeRawRequest = { + headers: requestHeaders, + path: request.url.pathname, + }; + const requestToUse = kibanaRequestFactory(fakeRawRequest); + + const result = await security.authc.apiKeys.uiam.invalidate(requestToUse, { id }); + + if (!result) { + return response.badRequest({ + body: { message: 'Failed to invalidate UIAM API key' }, + }); + } + + return response.ok({ body: result }); + } catch (err) { + logger.error(`Failed to invalidate UIAM API key: ${err}`, err); + return response.customError({ + statusCode: 500, + body: { message: err.message }, + }); + } + } + ); } diff --git a/x-pack/platform/test/security_functional/plugins/test_endpoints/tsconfig.json b/x-pack/platform/test/security_functional/plugins/test_endpoints/tsconfig.json index 02b23636d74d0..9820aa723d455 100644 --- a/x-pack/platform/test/security_functional/plugins/test_endpoints/tsconfig.json +++ b/x-pack/platform/test/security_functional/plugins/test_endpoints/tsconfig.json @@ -16,5 +16,7 @@ "@kbn/task-manager-plugin", "@kbn/config-schema", "@kbn/security-plugin-types-server", + "@kbn/core-http-server", + "@kbn/core-http-server-utils", ] }