Skip to content

Commit

Permalink
[kbn-test] add forceNewSession option to re-generate session cookie (e…
Browse files Browse the repository at this point in the history
…lastic#199018)

## Summary

Currently FTR caches `Cookies` of Cloud SAML sessions for the complete
FTR config run, meaning we only perform actual login once for the
specified role. It helps to optimise tests run time and improve
stability.

While it works most of the time, according to
https://github.com/elastic/kibana/issues/71866 Reporting test suite
stability depends on token validity (`20m`) and to stabilize it, this PR
adds `forceNewSession` option to force request a new SAML session when
it is required for specific tests.


```
      cookieCredentials = await samlAuth.getM2MApiCookieCredentialsWithRoleScope('admin', {
        forceNewSession: true,
      });
```

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
dmlemeshko and kibanamachine authored Nov 6, 2024
1 parent c35934e commit 7608d76
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { SamlSessionManager } from '@kbn/test';
import { GetCookieOptions, SamlSessionManager } from '@kbn/test';
import expect from '@kbn/expect';
import { REPO_ROOT } from '@kbn/repo-info';
import { resolve } from 'path';
Expand Down Expand Up @@ -91,16 +91,39 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) {
};

return {
async getInteractiveUserSessionCookieWithRoleScope(role: string) {
/**
* Returns a Cookie string containing the session token for the specified role.
* This string can be used to update browser cookies and login with the designated role.
*
* @param role - The SAML role for which the session token is required.
* @param options - Optional settings to control session behavior, such as forcing a new session.
* @returns A string with the Cookie token
*
* @throws If the specified role is a custom role without a predefined descriptor.
*/
async getInteractiveUserSessionCookieWithRoleScope(role: string, options?: GetCookieOptions) {
// Custom role has no descriptors by default, check if it was added before authentication
throwIfRoleNotSet(role, CUSTOM_ROLE, supportedRoleDescriptors);
return sessionManager.getInteractiveUserSessionCookieWithRoleScope(role);
return sessionManager.getInteractiveUserSessionCookieWithRoleScope(role, options);
},

async getM2MApiCookieCredentialsWithRoleScope(role: string): Promise<CookieCredentials> {
/**
* Returns an object containing a Cookie header with the session token for the specified role.
* This header can be used for authenticating API requests as the designated role.
*
* @param role - The SAML role for which the session token is required.
* @param options - Optional settings to control session behavior, such as forcing a new session.
* @returns An object with the Cookie header for API authentication.
*
* @throws If the specified role is a custom role without a predefined descriptor.
*/
async getM2MApiCookieCredentialsWithRoleScope(
role: string,
options?: GetCookieOptions
): Promise<CookieCredentials> {
// Custom role has no descriptors by default, check if it was added before authentication
throwIfRoleNotSet(role, CUSTOM_ROLE, supportedRoleDescriptors);
return sessionManager.getApiCredentialsForRole(role);
return sessionManager.getApiCredentialsForRole(role, options);
},

async getEmail(role: string) {
Expand Down Expand Up @@ -195,7 +218,7 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) {

expect(status).to.be(204);

// Update descriptors for custome role, it will be used to create API key
// Update descriptors for the custom role, it will be used to create API key
supportedRoleDescriptors.set(CUSTOM_ROLE, customRoleDescriptors);
},

Expand Down
7 changes: 6 additions & 1 deletion packages/kbn-test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ export { startServersCli, startServers } from './src/functional_tests/start_serv

// @internal
export { runTestsCli, runTests } from './src/functional_tests/run_tests';
export { SamlSessionManager, type SamlSessionManagerOptions, type HostOptions } from './src/auth';
export {
SamlSessionManager,
type SamlSessionManagerOptions,
type HostOptions,
type GetCookieOptions,
} from './src/auth';
export { runElasticsearch, runKibanaServer } from './src/functional_tests/lib';
export { getKibanaCliArg, getKibanaCliLoggers } from './src/functional_tests/lib/kibana_cli_args';

Expand Down
3 changes: 2 additions & 1 deletion packages/kbn-test/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

export {
SamlSessionManager,
type SamlSessionManagerOptions,
type GetCookieOptions,
type HostOptions,
type SamlSessionManagerOptions,
} from './session_manager';
59 changes: 59 additions & 0 deletions packages/kbn-test/src/auth/session_manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { ToolingLog } from '@kbn/tooling-log';
import crypto from 'crypto';
import { Cookie } from 'tough-cookie';
import { Session } from './saml_auth';
import { SamlSessionManager, SupportedRoles } from './session_manager';
Expand All @@ -33,6 +34,8 @@ const getSecurityProfileMock = jest.spyOn(samlAuth, 'getSecurityProfile');
const readCloudUsersFromFileMock = jest.spyOn(helper, 'readCloudUsersFromFile');
const isValidHostnameMock = jest.spyOn(helper, 'isValidHostname');

const getTestToken = () => 'kbn_cookie_' + crypto.randomBytes(16).toString('hex');

jest.mock('../kbn_client/kbn_client', () => {
return {
KbnClient: jest.fn(),
Expand Down Expand Up @@ -105,6 +108,34 @@ describe('SamlSessionManager', () => {
expect(createCloudSAMLSessionMock.mock.calls).toHaveLength(0);
});

test(`'getSessionCookieForRole' should call 'createLocalSAMLSession' again if 'forceNewSession = true'`, async () => {
const samlSessionManager = new SamlSessionManager(samlSessionManagerOptions);
createLocalSAMLSessionMock.mockResolvedValueOnce(
new Session(
Cookie.parse(`sid=${getTestToken()}; Path=/; Expires=Wed, 01 Oct 2023 07:00:00 GMT`)!,
testEmail
)
);
const cookieStr1 = await samlSessionManager.getInteractiveUserSessionCookieWithRoleScope(
roleViewer
);
createLocalSAMLSessionMock.mockResolvedValueOnce(
new Session(
Cookie.parse(`sid=${getTestToken()}; Path=/; Expires=Wed, 01 Oct 2023 08:00:00 GMT`)!,
testEmail
)
);
const cookieStr2 = await samlSessionManager.getInteractiveUserSessionCookieWithRoleScope(
roleViewer,
{
forceNewSession: true,
}
);
expect(createLocalSAMLSessionMock.mock.calls).toHaveLength(2);
expect(createCloudSAMLSessionMock.mock.calls).toHaveLength(0);
expect(cookieStr1).not.toEqual(cookieStr2);
});

test(`'getEmail' return the correct email`, async () => {
const samlSessionManager = new SamlSessionManager(samlSessionManagerOptions);
const email = await samlSessionManager.getEmail(roleEditor);
Expand Down Expand Up @@ -255,6 +286,34 @@ describe('SamlSessionManager', () => {
expect(createCloudSAMLSessionMock.mock.calls).toHaveLength(2);
});

test(`'getSessionCookieForRole' should call 'createCloudSAMLSession' again if 'forceNewSession = true'`, async () => {
const samlSessionManager = new SamlSessionManager(samlSessionManagerOptions);
createCloudSAMLSessionMock.mockResolvedValueOnce(
new Session(
Cookie.parse(`sid=${getTestToken()}; Path=/; Expires=Wed, 01 Oct 2023 07:00:00 GMT`)!,
cloudEmail
)
);
const cookieStr1 = await samlSessionManager.getInteractiveUserSessionCookieWithRoleScope(
roleViewer
);
createCloudSAMLSessionMock.mockResolvedValueOnce(
new Session(
Cookie.parse(`sid=${getTestToken()}; Path=/; Expires=Wed, 01 Oct 2023 08:00:00 GMT`)!,
cloudEmail
)
);
const cookieStr2 = await samlSessionManager.getInteractiveUserSessionCookieWithRoleScope(
roleViewer,
{
forceNewSession: true,
}
);
expect(createLocalSAMLSessionMock.mock.calls).toHaveLength(0);
expect(createCloudSAMLSessionMock.mock.calls).toHaveLength(2);
expect(cookieStr1).not.toEqual(cookieStr2);
});

test(`'getEmail' return the correct email`, async () => {
const samlSessionManager = new SamlSessionManager(samlSessionManagerOptions);
const email = await samlSessionManager.getEmail(roleViewer);
Expand Down
61 changes: 42 additions & 19 deletions packages/kbn-test/src/auth/session_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
getSecurityProfile,
Session,
} from './saml_auth';
import { Role, User } from './types';
import { GetSessionByRole, Role, User } from './types';

export interface HostOptions {
protocol: 'http' | 'https';
Expand All @@ -40,6 +40,10 @@ export interface SupportedRoles {
roles: string[];
}

export interface GetCookieOptions {
forceNewSession: boolean;
}

/**
* Manages cookies associated with user roles
*/
Expand Down Expand Up @@ -115,24 +119,32 @@ Set env variable 'TEST_CLOUD=1' to run FTR against your Cloud deployment`
}
};

private getSessionByRole = async (role: string) => {
if (this.sessionCache.has(role)) {
private getSessionByRole = async (options: GetSessionByRole): Promise<Session> => {
const { role, forceNewSession } = options;

// Validate role before creating SAML session
this.validateRole(role);

// Check if session is cached and not forced to create the new one
if (!forceNewSession && this.sessionCache.has(role)) {
return this.sessionCache.get(role)!;
}

// Validate role before creating SAML session
if (this.supportedRoles && !this.supportedRoles.roles.includes(role)) {
throw new Error(
`Role '${role}' is not in the supported list: ${this.supportedRoles.roles.join(
', '
)}. Add role descriptor in ${this.supportedRoles.sourcePath} to enable it for testing`
);
const session = await this.createSessionForRole(role);
this.sessionCache.set(role, session);

if (forceNewSession) {
this.log.debug(`Session for role '${role}' was force updated.`);
}

return session;
};

private createSessionForRole = async (role: string): Promise<Session> => {
let session: Session;

if (this.isCloud) {
this.log.debug(`new cloud SAML authentication with '${role}' role`);
this.log.debug(`Creating new cloud SAML session for role '${role}'`);
const kbnVersion = await this.kbnClient.version.get();
const { email, password } = this.getCloudUserByRole(role);
session = await createCloudSAMLSession({
Expand All @@ -143,7 +155,7 @@ Set env variable 'TEST_CLOUD=1' to run FTR against your Cloud deployment`
log: this.log,
});
} else {
this.log.debug(`new fake SAML authentication with '${role}' role`);
this.log.debug(`Creating new local SAML session for role '${role}'`);
session = await createLocalSAMLSession({
username: `elastic_${role}`,
email: `elastic_${role}@elastic.co`,
Expand All @@ -154,27 +166,38 @@ Set env variable 'TEST_CLOUD=1' to run FTR against your Cloud deployment`
});
}

this.sessionCache.set(role, session);
return session;
};

async getApiCredentialsForRole(role: string) {
const session = await this.getSessionByRole(role);
private validateRole = (role: string): void => {
if (this.supportedRoles && !this.supportedRoles.roles.includes(role)) {
throw new Error(
`Role '${role}' is not in the supported list: ${this.supportedRoles.roles.join(
', '
)}. Add role descriptor in ${this.supportedRoles.sourcePath} to enable it for testing`
);
}
};

async getApiCredentialsForRole(role: string, options?: GetCookieOptions) {
const { forceNewSession } = options || { forceNewSession: false };
const session = await this.getSessionByRole({ role, forceNewSession });
return { Cookie: `sid=${session.getCookieValue()}` };
}

async getInteractiveUserSessionCookieWithRoleScope(role: string) {
const session = await this.getSessionByRole(role);
async getInteractiveUserSessionCookieWithRoleScope(role: string, options?: GetCookieOptions) {
const { forceNewSession } = options || { forceNewSession: false };
const session = await this.getSessionByRole({ role, forceNewSession });
return session.getCookieValue();
}

async getEmail(role: string) {
const session = await this.getSessionByRole(role);
const session = await this.getSessionByRole({ role, forceNewSession: false });
return session.email;
}

async getUserData(role: string) {
const { cookie } = await this.getSessionByRole(role);
const { cookie } = await this.getSessionByRole({ role, forceNewSession: false });
const profileData = await getSecurityProfile({ kbnHost: this.kbnHost, cookie, log: this.log });
return profileData;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/kbn-test/src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,8 @@ export interface RetryParams {
attemptsCount: number;
attemptDelay: number;
}

export interface GetSessionByRole {
role: string;
forceNewSession: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ export default function ({ getService }: FtrProviderContext) {
};

describe('Data Stream', function () {
// see details: https://github.com/elastic/kibana/issues/198811
this.tags(['failsOnMKI']);
const generatedReports = new Set<string>();
before(async () => {
roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('admin');
cookieCredentials = await samlAuth.getM2MApiCookieCredentialsWithRoleScope('admin');
cookieCredentials = await samlAuth.getM2MApiCookieCredentialsWithRoleScope('admin', {
forceNewSession: true,
});
internalReqHeader = svlCommonApi.getInternalRequestHeader();

await esArchiver.load(archives.ecommerce.data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export default function ({ getService }: FtrProviderContext) {
this.timeout(12 * 60 * 1000);

before(async () => {
cookieCredentials = await samlAuth.getM2MApiCookieCredentialsWithRoleScope('admin');
cookieCredentials = await samlAuth.getM2MApiCookieCredentialsWithRoleScope('admin', {
forceNewSession: true,
});
internalReqHeader = svlCommonApi.getInternalRequestHeader();
});

Expand Down

0 comments on commit 7608d76

Please sign in to comment.