Skip to content

Commit

Permalink
[8.x] [FTR] allow to call roleScopedSupertest service with Cookie hea…
Browse files Browse the repository at this point in the history
…der (#192727) (#193679)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[FTR] allow to call roleScopedSupertest service with Cookie header
(#192727)](#192727)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Dzmitry
Lemechko","email":"[email protected]"},"sourceCommit":{"committedDate":"2024-09-19T23:10:18Z","message":"[FTR]
allow to call roleScopedSupertest service with Cookie header
(#192727)\n\n## Summary\r\n\r\nDuring the sync with kibana-security team
we agreed on how Kibana APIs\r\nshould be tested:\r\n\r\n### API
Authentication in Kibana: Public vs. Internal APIs\r\n\r\nKibana
provides both public and internal APIs, each requiring\r\nauthentication
with the correct privileges. However, the method of\r\ntesting these
APIs varies, depending on how they are untilized by
end\r\nusers.\r\n\r\n- **Public APIs**: When testing HTTP requests to
public APIs, **API\r\nkey-based authentication** should be used. It
reflect how end user call\r\nthese APIs. Due to existing restrictions,
we utilize `Admin` user\r\ncredentials to generate API keys for various
roles. While the API key\r\npermissions are correctly scoped according
to the assigned role, the\r\nuser will internally be recognized as
`Admin` during authentication.\r\n\r\n- **Internal APIs**: Direct HTTP
requests to internal APIs are generally\r\nnot expected. However, for
testing purposes, authentication should be\r\nperformed **using the
Cookie header**. This approach simulates\r\nclient-side behavior during
browser interactions, mirroring how internal\r\nAPIs are indirectly
invoked.\r\n\r\nTo simplify the process of creating/updating the tests,
this PR makes\r\nfew changes to `roleScopedSupertest` service\r\n\r\n1)
testing public APIs (nothing changed)\r\n\r\n```ts\r\n const
supertestAdminWithApiKey = await
roleScopedSupertest.getSupertestWithRoleScope('admin', {\r\n
withCustomHeaders: { 'accept-encoding': 'gzip' },\r\n });\r\n const
response = await
supertestAdminWithApiKey.get('/app/kibana');\r\n```\r\n\r\n2) testing
internal APIs\r\n```ts\r\n const supertestAdminWithCookieCredentials =
await roleScopedSupertest.getSupertestWithRoleScope(\r\n 'admin',\r\n
{\r\n useCookieHeader: true, // will use Cookie header instead of API
key\r\n withInternalHeaders: true,\r\n }\r\n );\r\n await
supertestAdminWithCookieCredentials\r\n
.post(`/internal/kibana/settings/${TEST_SETTING}`)\r\n .send({ value:
100 })\r\n .expect(200);\r\n```\r\n\r\nI updated some of the existing
tests according to the new approach.\r\n\r\nDocs for serverless and
deployment-agnostic api integration tests were\r\nupdated
accordingly","sha":"d9148f1d83e31f9f6a226c0c867e39d4aeb8d48f","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","v8.16.0","backport:version"],"title":"[FTR]
allow to call roleScopedSupertest service with Cookie
header","number":192727,"url":"https://github.com/elastic/kibana/pull/192727","mergeCommit":{"message":"[FTR]
allow to call roleScopedSupertest service with Cookie header
(#192727)\n\n## Summary\r\n\r\nDuring the sync with kibana-security team
we agreed on how Kibana APIs\r\nshould be tested:\r\n\r\n### API
Authentication in Kibana: Public vs. Internal APIs\r\n\r\nKibana
provides both public and internal APIs, each requiring\r\nauthentication
with the correct privileges. However, the method of\r\ntesting these
APIs varies, depending on how they are untilized by
end\r\nusers.\r\n\r\n- **Public APIs**: When testing HTTP requests to
public APIs, **API\r\nkey-based authentication** should be used. It
reflect how end user call\r\nthese APIs. Due to existing restrictions,
we utilize `Admin` user\r\ncredentials to generate API keys for various
roles. While the API key\r\npermissions are correctly scoped according
to the assigned role, the\r\nuser will internally be recognized as
`Admin` during authentication.\r\n\r\n- **Internal APIs**: Direct HTTP
requests to internal APIs are generally\r\nnot expected. However, for
testing purposes, authentication should be\r\nperformed **using the
Cookie header**. This approach simulates\r\nclient-side behavior during
browser interactions, mirroring how internal\r\nAPIs are indirectly
invoked.\r\n\r\nTo simplify the process of creating/updating the tests,
this PR makes\r\nfew changes to `roleScopedSupertest` service\r\n\r\n1)
testing public APIs (nothing changed)\r\n\r\n```ts\r\n const
supertestAdminWithApiKey = await
roleScopedSupertest.getSupertestWithRoleScope('admin', {\r\n
withCustomHeaders: { 'accept-encoding': 'gzip' },\r\n });\r\n const
response = await
supertestAdminWithApiKey.get('/app/kibana');\r\n```\r\n\r\n2) testing
internal APIs\r\n```ts\r\n const supertestAdminWithCookieCredentials =
await roleScopedSupertest.getSupertestWithRoleScope(\r\n 'admin',\r\n
{\r\n useCookieHeader: true, // will use Cookie header instead of API
key\r\n withInternalHeaders: true,\r\n }\r\n );\r\n await
supertestAdminWithCookieCredentials\r\n
.post(`/internal/kibana/settings/${TEST_SETTING}`)\r\n .send({ value:
100 })\r\n .expect(200);\r\n```\r\n\r\nI updated some of the existing
tests according to the new approach.\r\n\r\nDocs for serverless and
deployment-agnostic api integration tests were\r\nupdated
accordingly","sha":"d9148f1d83e31f9f6a226c0c867e39d4aeb8d48f"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/192727","number":192727,"mergeCommit":{"message":"[FTR]
allow to call roleScopedSupertest service with Cookie header
(#192727)\n\n## Summary\r\n\r\nDuring the sync with kibana-security team
we agreed on how Kibana APIs\r\nshould be tested:\r\n\r\n### API
Authentication in Kibana: Public vs. Internal APIs\r\n\r\nKibana
provides both public and internal APIs, each requiring\r\nauthentication
with the correct privileges. However, the method of\r\ntesting these
APIs varies, depending on how they are untilized by
end\r\nusers.\r\n\r\n- **Public APIs**: When testing HTTP requests to
public APIs, **API\r\nkey-based authentication** should be used. It
reflect how end user call\r\nthese APIs. Due to existing restrictions,
we utilize `Admin` user\r\ncredentials to generate API keys for various
roles. While the API key\r\npermissions are correctly scoped according
to the assigned role, the\r\nuser will internally be recognized as
`Admin` during authentication.\r\n\r\n- **Internal APIs**: Direct HTTP
requests to internal APIs are generally\r\nnot expected. However, for
testing purposes, authentication should be\r\nperformed **using the
Cookie header**. This approach simulates\r\nclient-side behavior during
browser interactions, mirroring how internal\r\nAPIs are indirectly
invoked.\r\n\r\nTo simplify the process of creating/updating the tests,
this PR makes\r\nfew changes to `roleScopedSupertest` service\r\n\r\n1)
testing public APIs (nothing changed)\r\n\r\n```ts\r\n const
supertestAdminWithApiKey = await
roleScopedSupertest.getSupertestWithRoleScope('admin', {\r\n
withCustomHeaders: { 'accept-encoding': 'gzip' },\r\n });\r\n const
response = await
supertestAdminWithApiKey.get('/app/kibana');\r\n```\r\n\r\n2) testing
internal APIs\r\n```ts\r\n const supertestAdminWithCookieCredentials =
await roleScopedSupertest.getSupertestWithRoleScope(\r\n 'admin',\r\n
{\r\n useCookieHeader: true, // will use Cookie header instead of API
key\r\n withInternalHeaders: true,\r\n }\r\n );\r\n await
supertestAdminWithCookieCredentials\r\n
.post(`/internal/kibana/settings/${TEST_SETTING}`)\r\n .send({ value:
100 })\r\n .expect(200);\r\n```\r\n\r\nI updated some of the existing
tests according to the new approach.\r\n\r\nDocs for serverless and
deployment-agnostic api integration tests were\r\nupdated
accordingly","sha":"d9148f1d83e31f9f6a226c0c867e39d4aeb8d48f"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Dzmitry Lemechko <[email protected]>
  • Loading branch information
kibanamachine and dmlemeshko authored Sep 23, 2024
1 parent c56a365 commit ba164e2
Show file tree
Hide file tree
Showing 39 changed files with 802 additions and 939 deletions.
6 changes: 5 additions & 1 deletion packages/kbn-ftr-common-functional-services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ export type Es = ProvidedType<typeof EsProvider>;
import { SupertestWithoutAuthProvider } from './services/supertest_without_auth';
export type SupertestWithoutAuthProviderType = ProvidedType<typeof SupertestWithoutAuthProvider>;

export type { InternalRequestHeader, RoleCredentials } from './services/saml_auth';
export type {
InternalRequestHeader,
RoleCredentials,
CookieCredentials,
} from './services/saml_auth';

import { SamlAuthProvider } from './services/saml_auth/saml_auth_provider';
export type SamlAuthProviderType = ProvidedType<typeof SamlAuthProvider>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
*/

export { SamlAuthProvider } from './saml_auth_provider';
export type { RoleCredentials } from './saml_auth_provider';
export type { RoleCredentials, CookieCredentials } from './saml_auth_provider';
export type { InternalRequestHeader } from './default_request_headers';
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import { InternalRequestHeader } from './default_request_headers';
export interface RoleCredentials {
apiKey: { id: string; name: string };
apiKeyHeader: { Authorization: string };
cookieHeader: { Cookie: string };
}

export interface CookieCredentials {
Cookie: string;
// supertest.set() expects an object that matches IncomingHttpHeaders type, that needs to accept arbitrary key-value pairs as headers
// We extend the interface with an index signature to resolve this.
[header: string]: string;
}

export function SamlAuthProvider({ getService }: FtrProviderContext) {
Expand Down Expand Up @@ -60,7 +66,7 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) {
async getInteractiveUserSessionCookieWithRoleScope(role: string) {
return sessionManager.getInteractiveUserSessionCookieWithRoleScope(role);
},
async getM2MApiCredentialsWithRoleScope(role: string) {
async getM2MApiCookieCredentialsWithRoleScope(role: string): Promise<CookieCredentials> {
return sessionManager.getApiCredentialsForRole(role);
},
async getEmail(role: string) {
Expand All @@ -76,7 +82,7 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) {
},
async createM2mApiKeyWithRoleScope(role: string): Promise<RoleCredentials> {
// Get admin credentials in order to create the API key
const adminCookieHeader = await this.getM2MApiCredentialsWithRoleScope('admin');
const adminCookieHeader = await this.getM2MApiCookieCredentialsWithRoleScope('admin');

// Get the role descrtiptor for the role
let roleDescriptors = {};
Expand Down Expand Up @@ -110,9 +116,12 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) {
const apiKeyHeader = { Authorization: 'ApiKey ' + apiKey.encoded };

log.debug(`Created api key for role: [${role}]`);
return { apiKey, apiKeyHeader, cookieHeader: adminCookieHeader };
return { apiKey, apiKeyHeader };
},
async invalidateM2mApiKeyWithRoleScope(roleCredentials: RoleCredentials) {
// Get admin credentials in order to invalidate the API key
const adminCookieHeader = await this.getM2MApiCookieCredentialsWithRoleScope('admin');

const requestBody = {
apiKeys: [
{
Expand All @@ -126,7 +135,7 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) {
const { status } = await supertestWithoutAuth
.post('/internal/security/api_key/invalidate')
.set(INTERNAL_REQUEST_HEADERS)
.set(roleCredentials.cookieHeader)
.set(adminCookieHeader)
.send(requestBody);

expect(status).to.be(200);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import type { IndexDetails } from '@kbn/cloud-security-posture-common';
import { CLOUD_SECURITY_PLUGIN_VERSION } from '@kbn/cloud-security-posture-plugin/common/constants';
import { SecurityService } from '@kbn/ftr-common-functional-ui-services';

export interface RoleCredentials {
apiKey: { id: string; name: string };
apiKeyHeader: { Authorization: string };
cookieHeader: { Cookie: string };
}
import { RoleCredentials } from '@kbn/ftr-common-functional-services';

export const deleteIndex = async (es: Client, indexToBeDeleted: string[]) => {
return Promise.all([
Expand Down
14 changes: 14 additions & 0 deletions x-pack/test/api_integration/deployment_agnostic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ export type DeploymentAgnosticFtrProviderContext = GenericFtrProviderContext<typ

3. Add Tests

API Authentication in Kibana: Public vs. Internal APIs

Kibana provides both public and internal APIs, each requiring authentication with the correct privileges. However, the method of testing these APIs varies, depending on how they are untilized by end users.

- Public APIs: When testing HTTP requests to public APIs, API key-based authentication should be used. It reflect how end user call these APIs. Due to existing restrictions, we utilize `Admin` user credentials to generate API keys for various roles. While the API key permissions are correctly scoped according to the assigned role, the user will internally be recognized as `Admin` during authentication.

- Internal APIs: Direct HTTP requests to internal APIs are generally not expected. However, for testing purposes, authentication should be performed using the Cookie header. This approach simulates client-side behavior during browser interactions, mirroring how internal APIs are indirectly invoked.

Recommendations:
- use `roleScopedSupertest` service to create supertest instance scoped to specific role and pre-defined request headers
- `roleScopedSupertest.getSupertestWithRoleScope(<role>)` authenticate requests with API key by default
- pass `withCookieHeader: true` to use Cookie header for requests authentication
- don't forget to invalidate API key using `destroy()` on supertest scoped instance in `after` hook

Add test files to `x-pack/test/<my_own_api_integration_folder>/deployment_agnostic/apis/<my_api>`:

test example
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,51 +7,66 @@

import {
RoleCredentials,
CookieCredentials,
SupertestWithoutAuthProviderType,
SamlAuthProviderType,
} from '@kbn/ftr-common-functional-services';
import { Test } from 'supertest';
import { DeploymentAgnosticFtrProviderContext } from '../ftr_provider_context';

export interface RequestHeadersOptions {
useCookieHeader?: boolean;
withInternalHeaders?: boolean;
withCommonHeaders?: boolean;
withCustomHeaders?: Record<string, string>;
}

export class SupertestWithRoleScope {
private roleAuthc: RoleCredentials | null;
private authValue: RoleCredentials | CookieCredentials | null;
private readonly supertestWithoutAuth: SupertestWithoutAuthProviderType;
private samlAuth: SamlAuthProviderType;
private readonly options: RequestHeadersOptions;

constructor(
roleAuthc: RoleCredentials,
authValue: RoleCredentials | CookieCredentials | null,
supertestWithoutAuth: SupertestWithoutAuthProviderType,
samlAuth: SamlAuthProviderType,
options: RequestHeadersOptions
) {
this.roleAuthc = roleAuthc;
this.authValue = authValue;
this.supertestWithoutAuth = supertestWithoutAuth;
this.samlAuth = samlAuth;
this.options = options;
}

private isRoleCredentials(value: any): value is RoleCredentials {
return value && typeof value === 'object' && 'apiKey' in value && 'apiKeyHeader' in value;
}

async destroy() {
if (this.roleAuthc) {
await this.samlAuth.invalidateM2mApiKeyWithRoleScope(this.roleAuthc);
this.roleAuthc = null;
if (this.isRoleCredentials(this.authValue)) {
await this.samlAuth.invalidateM2mApiKeyWithRoleScope(this.authValue);
this.authValue = null;
}
}

private addHeaders(agent: Test): Test {
const { withInternalHeaders, withCommonHeaders, withCustomHeaders } = this.options;
const { useCookieHeader, withInternalHeaders, withCommonHeaders, withCustomHeaders } =
this.options;

if (!this.roleAuthc) {
throw new Error('The instance has already been destroyed.');
if (useCookieHeader) {
if (!this.authValue || !('Cookie' in this.authValue)) {
throw new Error('The instance has already been destroyed or cookieHeader is missing.');
}
// set cookie header
void agent.set(this.authValue);
} else {
if (!this.authValue || !this.isRoleCredentials(this.authValue)) {
throw new Error('The instance has already been destroyed or roleAuthc is missing.');
}
// set API key header
void agent.set(this.authValue.apiKeyHeader);
}
// set role-based API key by default
void agent.set(this.roleAuthc.apiKeyHeader);

if (withInternalHeaders) {
void agent.set(this.samlAuth.getInternalRequestHeader());
Expand All @@ -69,7 +84,7 @@ export class SupertestWithRoleScope {
}

private request(method: 'post' | 'get' | 'put' | 'delete', url: string): Test {
if (!this.roleAuthc) {
if (!this.authValue) {
throw new Error('Instance has been destroyed and cannot be used for making requests.');
}
const agent = this.supertestWithoutAuth[method](url);
Expand Down Expand Up @@ -101,6 +116,9 @@ export class SupertestWithRoleScope {
*
* Use this service to easily test API endpoints with role-specific authorization and
* custom headers, both in serverless and stateful environments.
*
* Pass '{ useCookieHeader: true }' to use Cookie header for authentication instead of API key.
* It is the correct way to perform HTTP requests for internal end-points.
*/
export function RoleScopedSupertestProvider({ getService }: DeploymentAgnosticFtrProviderContext) {
const supertestWithoutAuth = getService('supertestWithoutAuth');
Expand All @@ -110,10 +128,18 @@ export function RoleScopedSupertestProvider({ getService }: DeploymentAgnosticFt
async getSupertestWithRoleScope(
role: string,
options: RequestHeadersOptions = {
useCookieHeader: false,
withCommonHeaders: false,
withInternalHeaders: false,
}
) {
// if 'useCookieHeader' set to 'true', HTTP requests will be called with cookie Header (like in browser)
if (options.useCookieHeader) {
const cookieHeader = await samlAuth.getM2MApiCookieCredentialsWithRoleScope(role);
return new SupertestWithRoleScope(cookieHeader, supertestWithoutAuth, samlAuth, options);
}

// HTTP requests will be called with API key in header by default
const roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope(role);
return new SupertestWithRoleScope(roleAuthc, supertestWithoutAuth, samlAuth, options);
},
Expand Down
54 changes: 43 additions & 11 deletions x-pack/test_serverless/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,28 +143,60 @@ describe("my test suite", async function() {

#### API integration test example

API Authentication in Kibana: Public vs. Internal APIs

Kibana provides both public and internal APIs, each requiring authentication with the correct privileges. However, the method of testing these APIs varies, depending on how they are untilized by end users.

- Public APIs: When testing HTTP requests to public APIs, API key-based authentication should be used. It reflects how an end user calls these APIs. Due to existing restrictions, we utilize `Admin` user credentials to generate API keys for various roles. While the API key permissions are correctly scoped according to the assigned role, the user will internally be recognized as `Admin` during authentication.

- Internal APIs: Direct HTTP requests to internal APIs are generally not expected. However, for testing purposes, authentication should be performed using the Cookie header. This approach simulates client-side behavior during browser interactions, mirroring how internal APIs are indirectly invoked.

Recommendations:
- in each test file top level `describe` suite should start with `createM2mApiKeyWithRoleScope` call in `before` hook
- don't forget to invalidate api key using `invalidateApiKeyWithRoleScope` in `after` hook
- make api calls using `supertestWithoutAuth` with generated api key header
- use `roleScopedSupertest` service to create a supertest instance scoped to a specific role and predefined request headers
- `roleScopedSupertest.getSupertestWithRoleScope(<role>)` authenticates requests with an API key by default
- pass `withCookieHeader: true` to use Cookie header for request authentication
- don't forget to invalidate API keys by using `destroy()` on the supertest scoped instance in the `after` hook

```
describe("my test suite", async function() {
describe("my public APIs test suite", async function() {
before(async () => {
roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('viewer');
commonRequestHeader = svlCommonApi.getCommonRequestHeader();
internalRequestHeader = svlCommonApi.getInternalRequestHeader();
supertestViewerWithApiKey =
await roleScopedSupertest.getSupertestWithRoleScope('viewer', {
withInternalHeaders: true,
});
});
after(async () => {
await svlUserManager.invalidateApiKeyWithRoleScope(roleAuthc);
await supertestViewerWithApiKey.destroy();
});
it(''test step', async () => {
const { body, status } = await supertestWithoutAuth
const { body, status } = await supertestViewerWithApiKey
.delete('/api/spaces/space/default')
.set(commonRequestHeader)
.set(roleAuthc.apiKeyHeader);
...
});
});
```

```
describe("my internal APIs test suite", async function() {
before(async () => {
supertestViewerWithCookieCredentials =
await roleScopedSupertest.getSupertestWithRoleScope('admin', {
withCookieHeader: true, // to avoid generating API key and use Cookie header instead
withInternalHeaders: true,
});
});
after(async () => {
// no need to call '.destroy' since we didn't create API key and Cookie persist for the role within FTR run
});
it(''test step', async () => {
await supertestAdminWithCookieCredentials
.post(`/internal/kibana/settings`)
.send({ changes: { [TEST_SETTING]: 500 } })
.expect(200);
...
});
});
Expand Down
Loading

0 comments on commit ba164e2

Please sign in to comment.