[scout] support login with a custom role (#213798)

## Summary

Adding custom roles support in Scout UI tests

Example:

```
    test.beforeEach(async ({ browserAuth, pageObjects }) => {
      await browserAuth.loginWithCustomRole({
        elasticsearch: {
          cluster: ['manage'],
          indices: [
            {
              names: ['.siem-signals*', '.lists-*', '.items-*'],
              privileges: ['read', 'view_index_metadata'],
              allow_restricted_indices: false,
            },
            {
              names: ['.alerts*', '.preview.alerts*'],
              privileges: ['read', 'view_index_metadata'],
              allow_restricted_indices: false,
            },
          ],
        },
        kibana: [
          {
            base: [],
            feature: {
              siemV2: ['read', 'read_alerts'],
            },
            spaces: ['*'],
          },
        ],
      });
      await pageObjects.dashboard.goto();
```

In `kbn/scout-security` to login as `platform_engineer` we will need to
override browser auth fixture with smth like:

```ts
const resourcePath = path.resolve(SERVERLESS_ROLES_ROOT_PATH, 'security', 'roles.yml');
const svlRoleDescriptors = new Map<string, any>(
    Object.entries(readRolesDescriptorsFromResource(resourcePath) as Record<string, unknown>)
);

const loginAsPlatformEngineer = async () => {
  const roleName = 'platform_engineer';
  if (!serverless) {
      const roleDesciptor = svlRoleDescriptors?.get(roleName) as ElasticsearchRoleDescriptor;
      if (!roleDesciptor) {
        throw new Error(`No role descriptors found for ${roleName}`);
      }
      await samlAuth.setCustomRole(roleDesciptor);
      return loginAs(samlAuth.customRoleName);
  } else {
    await loginAs(roleName);
  }
}
```

This way we gonna use custom role to replicate serverless default roles
in Stateful run (and support deployment agnostic approach)

---------

Co-authored-by: Cesare de Cal <cesare.decal@elastic.co>
This commit is contained in:
Dzmitry Lemechko 2025-03-19 10:50:32 +01:00 committed by GitHub
parent c6b594cfee
commit ef32357d80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 178 additions and 14 deletions

View file

@ -37,6 +37,8 @@ export type {
ScoutLogger,
ScoutServerConfig,
ScoutTestConfig,
KibanaRole,
ElasticsearchRoleDescriptor,
} from './src/types';
// re-export from Playwright

View file

@ -0,0 +1,68 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EsClient, KbnClient } from '.';
export interface KibanaRole {
elasticsearch: {
cluster: string[];
indices: Array<{
names: string[];
privileges: string[];
allow_restricted_indices?: boolean | undefined;
}>;
};
kibana: Array<{
base: string[];
feature: Record<string, string[]>;
spaces: string[];
}>;
}
export interface ElasticsearchRoleDescriptor {
cluster?: string[];
indices?: Array<{
names: string[];
privileges: string[];
allow_restricted_indices?: boolean;
}>;
applications?: Array<{
application: string;
privileges: string[];
resources: string[];
}>;
run_as?: string[];
}
export const createCustomRole = async (
kbnClient: KbnClient,
customRoleName: string,
role: KibanaRole
) => {
const { status } = await kbnClient.request({
method: 'PUT',
path: `/api/security/role/${customRoleName}`,
body: role,
});
if (status !== 204) {
throw new Error(`Failed to set custom role with status: ${status}`);
}
};
export const createElasticsearchCustomRole = async (
client: EsClient,
customRoleName: string,
role: ElasticsearchRoleDescriptor
) => {
await client.security.putRole({
name: customRoleName,
...role,
});
};

View file

@ -19,3 +19,5 @@ export type { SamlSessionManager } from '@kbn/test';
export type { ScoutLogger } from './logger';
export type { KbnClient } from '@kbn/test';
export type { Client as EsClient } from '@elastic/elasticsearch';
export { createCustomRole, createElasticsearchCustomRole } from './custom_role';
export type { ElasticsearchRoleDescriptor, KibanaRole } from './custom_role';

View file

@ -42,8 +42,9 @@ const createKibanaHostOptions = (config: ScoutTestConfig): HostOptions => {
export const createSamlSessionManager = (
config: ScoutTestConfig,
log: ScoutLogger
): SamlSessionManager => {
log: ScoutLogger,
customRoleName?: string
) => {
const resourceDirPath = getResourceDirPath(config);
const rolesDefinitionPath = path.resolve(resourceDirPath, 'roles.yml');
@ -51,7 +52,8 @@ export const createSamlSessionManager = (
string,
unknown
>;
const supportedRoles = Object.keys(supportedRoleDescriptors);
const supportedRoles = [...Object.keys(supportedRoleDescriptors)].concat(customRoleName || []);
const sessionManager = new SamlSessionManager({
hostOptions: createKibanaHostOptions(config),

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { PROJECT_DEFAULT_ROLES } from '../../../../common';
import { ElasticsearchRoleDescriptor, KibanaRole, PROJECT_DEFAULT_ROLES } from '../../../../common';
import { coreWorkerFixtures } from '../../worker';
export type LoginFunction = (role: string) => Promise<void>;
@ -28,6 +28,12 @@ export interface BrowserAuthFixture {
* @returns A Promise that resolves once the cookie in browser is set.
*/
loginAsPrivilegedUser: () => Promise<void>;
/**
* Logs in as a user with a custom role.
* @param role - A role object that defines the Kibana and ES previleges. Role will re-created if it doesn't exist.
* @returns A Promise that resolves once the cookie in browser is set.
*/
loginWithCustomRole: (role: KibanaRole) => Promise<void>;
}
/**
@ -36,7 +42,7 @@ export interface BrowserAuthFixture {
* for the specified role and the "context" fixture to update the cookie with the role-scoped session.
*/
export const browserAuthFixture = coreWorkerFixtures.extend<{ browserAuth: BrowserAuthFixture }>({
browserAuth: async ({ log, context, samlAuth, config, kbnUrl }, use) => {
browserAuth: async ({ log, context, samlAuth, config, kbnUrl, esClient }, use) => {
const setSessionCookie = async (cookieValue: string) => {
await context.clearCookies();
await context.addCookies([
@ -49,11 +55,19 @@ export const browserAuthFixture = coreWorkerFixtures.extend<{ browserAuth: Brows
]);
};
const loginAs: LoginFunction = async (role) => {
const cookie = await samlAuth.getInteractiveUserSessionCookieWithRoleScope(role);
let isCustomRoleCreated = false;
const loginAs: LoginFunction = async (role: string) => {
const cookie = await samlAuth.session.getInteractiveUserSessionCookieWithRoleScope(role);
await setSessionCookie(cookie);
};
const loginWithCustomRole = async (role: KibanaRole | ElasticsearchRoleDescriptor) => {
await samlAuth.setCustomRole(role);
isCustomRoleCreated = true;
return loginAs(samlAuth.customRoleName);
};
const loginAsAdmin = () => loginAs('admin');
const loginAsViewer = () => loginAs('viewer');
const loginAsPrivilegedUser = () => {
@ -64,6 +78,16 @@ export const browserAuthFixture = coreWorkerFixtures.extend<{ browserAuth: Brows
};
log.serviceLoaded('browserAuth');
await use({ loginAsAdmin, loginAsViewer, loginAsPrivilegedUser });
await use({
loginAsAdmin,
loginAsViewer,
loginAsPrivilegedUser,
loginWithCustomRole,
});
if (isCustomRoleCreated) {
log.debug(`Deleting custom role with name ${samlAuth.customRoleName}`);
await esClient.security.deleteRole({ name: samlAuth.customRoleName });
}
},
});

View file

@ -19,6 +19,10 @@ import {
KibanaUrl,
getLogger,
ScoutLogger,
createElasticsearchCustomRole,
createCustomRole,
ElasticsearchRoleDescriptor,
KibanaRole,
} from '../../../common/services';
import type { ScoutTestOptions } from '../../types';
import type { ScoutTestConfig } from '.';
@ -30,6 +34,12 @@ export type { KibanaUrl } from '../../../common/services/kibana_url';
export type { ScoutTestConfig } from '../../../types';
export type { ScoutLogger } from '../../../common/services/logger';
export interface SamlAuth {
session: SamlSessionManager;
customRoleName: string;
setCustomRole(role: KibanaRole | ElasticsearchRoleDescriptor): Promise<void>;
}
/**
* The coreWorkerFixtures setup defines foundational fixtures that are essential
* for running tests in the "kbn-scout" framework. These fixtures provide reusable,
@ -45,7 +55,7 @@ export const coreWorkerFixtures = base.extend<
kbnUrl: KibanaUrl;
esClient: Client;
kbnClient: KbnClient;
samlAuth: SamlSessionManager;
samlAuth: SamlAuth;
}
>({
// Provides a scoped logger instance for each worker to use in fixtures and tests.
@ -114,14 +124,41 @@ export const coreWorkerFixtures = base.extend<
/**
* Creates a SAML session manager, that handles authentication tasks for tests involving
* SAML-based authentication.
* SAML-based authentication. Exposes a method to set a custom role for the session.
*
* Note: In order to speedup execution of tests, we cache the session cookies for each role
* after first call.
*/
samlAuth: [
({ log, config }, use) => {
use(createSamlSessionManager(config, log));
({ log, config, esClient, kbnClient }, use, workerInfo) => {
let customRoleHash = '';
const customRoleName = `custom_role_worker_${workerInfo.parallelIndex}`;
const session = createSamlSessionManager(config, log, customRoleName);
const isCustomRoleSet = (roleHash: string) => roleHash === customRoleHash;
const isElasticsearchRole = (role: any): role is ElasticsearchRoleDescriptor => {
return 'applications' in role;
};
const setCustomRole = async (role: KibanaRole | ElasticsearchRoleDescriptor) => {
const newRoleHash = JSON.stringify(role);
if (isCustomRoleSet(newRoleHash)) {
log.info(`Custom role is already set`);
return;
}
if (isElasticsearchRole(role)) {
await createElasticsearchCustomRole(esClient, customRoleName, role);
} else {
await createCustomRole(kbnClient, customRoleName, role);
}
customRoleHash = newRoleHash;
};
use({ session, customRoleName, setCustomRole });
},
{ scope: 'worker' },
],

View file

@ -14,7 +14,7 @@ export type {
KibanaUrl,
EsClient,
KbnClient,
SamlSessionManager,
SamlAuth,
} from './core_fixtures';
export { esArchiverFixture } from './es_archiver';

View file

@ -7,4 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { EsClient, KbnClient, KibanaUrl, SamlSessionManager, ScoutLogger } from '../common';
export type {
EsClient,
KbnClient,
KibanaUrl,
SamlSessionManager,
ScoutLogger,
KibanaRole,
ElasticsearchRoleDescriptor,
} from '../common';

View file

@ -320,6 +320,23 @@ describe('SamlSessionManager', () => {
expect(email).toBe(cloudEmail);
});
test(`'getSupportedRoles' return empty array when roles by default`, async () => {
const samlSessionManager = new SamlSessionManager({
...samlSessionManagerOptions,
});
const roles = samlSessionManager.getSupportedRoles();
expect(roles).toEqual([]);
});
test(`'getSupportedRoles' return the correct roles when roles were defined`, async () => {
const samlSessionManager = new SamlSessionManager({
...samlSessionManagerOptions,
supportedRoles,
});
const roles = samlSessionManager.getSupportedRoles();
expect(roles).toBe(supportedRoles.roles);
});
test(`'getUserData' should call security API and return user profile data`, async () => {
const testData: UserProfile = {
username: '92qab123',

View file

@ -211,4 +211,8 @@ Set env variable 'TEST_CLOUD=1' to run FTR against your Cloud deployment`
const profileData = await getSecurityProfile({ kbnHost: this.kbnHost, cookie, log: this.log });
return profileData;
}
getSupportedRoles() {
return this.supportedRoles ? this.supportedRoles.roles : [];
}
}