[FTR] [deployment agnostic tests] move saml roles setup to kbn/es (#190489)

## Summary

closes #190212

This PR moves realm roles and mapping creation from FTR service to
`kbn/es` package in oder to configure ES properly when you start servers
with ` node scripts/functional_tests_server.js --config
<path/to/stateful_config_with_SAML_setup>`

It allows to start servers and investigate test scenario manually (e.g.
reproduce failure)

Thanks a lot to @azasypkin for ideas how to simplify and minimize the
code change.

Instead of 2 API calls to ES in FTR service we copy roles.yml to ES
config directory before its start and create role mapping via API (but
using template) as a part of security setup

How to test: 
```
node scripts/functional_tests_server.js --config x-pack/test/api_integration/deployment_agnostic/stateful.config.ts
curl -X GET "http://localhost:9220/_security/role_mapping" -H "Content-Type: application/json" -u elastic:changeme
```

you should get the valid mapping back

Note: if you try to load Kibana manually, it won't work due to missing
mock-idp-plugin in stateful #190221 But automated tests are functional
and can be run via script.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
This commit is contained in:
Dzmitry Lemechko 2024-08-15 15:52:02 +02:00 committed by GitHub
parent 90a435cf8f
commit da1db2cdeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 58 additions and 148 deletions

View file

@ -18,6 +18,7 @@ import { promisify } from 'util';
import { CA_CERT_PATH, ES_NOPASSWORD_P12_PATH, extract } from '@kbn/dev-utils';
import { ToolingLog } from '@kbn/tooling-log';
import treeKill from 'tree-kill';
import { MOCK_IDP_REALM_NAME, ensureSAMLRoleMapping } from '@kbn/mock-idp-utils';
import { downloadSnapshot, installSnapshot, installSource, installArchive } from './install';
import { ES_BIN, ES_PLUGIN_BIN, ES_KEYSTORE_BIN } from './paths';
import {
@ -312,7 +313,7 @@ export class Cluster {
*/
private exec(installPath: string, opts: EsClusterExecOptions) {
const {
skipNativeRealmSetup = false,
skipSecuritySetup = false,
reportTime = () => {},
startTime,
skipReadyCheck,
@ -437,8 +438,8 @@ export class Cluster {
});
}
// once the cluster is ready setup the native realm
if (!skipNativeRealmSetup) {
// once the cluster is ready setup the realm
if (!skipSecuritySetup) {
const nativeRealm = new NativeRealm({
log: this.log,
elasticPassword: options.password,
@ -446,8 +447,12 @@ export class Cluster {
});
await nativeRealm.setPasswords(options);
}
const samlRealmConfigPrefix = `authc.realms.saml.${MOCK_IDP_REALM_NAME}.`;
if (args.some((arg) => arg.includes(samlRealmConfigPrefix))) {
await ensureSAMLRoleMapping(client);
}
}
this.log.success('kbn/es setup complete');
});

View file

@ -7,7 +7,7 @@
*/
export interface EsClusterExecOptions {
skipNativeRealmSetup?: boolean;
skipSecuritySetup?: boolean;
reportTime?: (...args: any[]) => void;
startTime?: number;
esArgs?: string[] | string;

View file

@ -18,7 +18,7 @@ import { ToolingLog } from '@kbn/tooling-log';
import { BASE_PATH, ES_CONFIG, ES_KEYSTORE_BIN } from '../paths';
import { Artifact } from '../artifact';
import { parseSettings, SettingsFilter } from '../settings';
import { log as defaultLog } from '../utils/log';
import { log as defaultLog, isFile, copyFileSync } from '../utils';
import { InstallArchiveOptions } from './types';
const isHttpUrl = (str: string) => {
@ -41,6 +41,7 @@ export async function installArchive(archive: string, options?: InstallArchiveOp
log = defaultLog,
esArgs = [],
disableEsTmpDir = process.env.FTR_DISABLE_ES_TMPDIR?.toLowerCase() === 'true',
resources,
} = options || {};
let dest = archive;
@ -84,6 +85,23 @@ export async function installArchive(archive: string, options?: InstallArchiveOp
...parseSettings(esArgs, { filter: SettingsFilter.SecureOnly }),
]);
// copy resources to ES config directory
if (resources) {
resources.forEach((resource) => {
if (!isFile(resource)) {
throw new Error(
`Invalid resource: '${resource}'.\nOnly valid files can be copied to ES config directory`
);
}
const filename = path.basename(resource);
const destPath = path.resolve(installPath, 'config', filename);
copyFileSync(resource, destPath);
log.info('moved %s in config to %s', resource, destPath);
});
}
return { installPath, disableEsTmpDir };
}

View file

@ -51,6 +51,7 @@ export async function installSnapshot({
log = defaultLog,
esArgs,
useCached = false,
resources,
}: InstallSnapshotOptions) {
const { downloadPath } = await downloadSnapshot({
license,
@ -68,5 +69,6 @@ export async function installSnapshot({
installPath,
log,
esArgs,
resources,
});
}

View file

@ -33,6 +33,7 @@ export async function installSource({
installPath = path.resolve(basePath, 'source'),
log = defaultLog,
esArgs,
resources,
}: InstallSourceOptions) {
log.info('source path: %s', chalk.bold(sourcePath));
log.info('install path: %s', chalk.bold(installPath));
@ -59,6 +60,7 @@ export async function installSource({
installPath,
log,
esArgs,
resources,
});
}

View file

@ -17,6 +17,7 @@ export interface InstallSourceOptions {
installPath?: string;
log?: ToolingLog;
esArgs?: string[];
resources?: string[];
}
export interface DownloadSnapshotOptions {
@ -26,6 +27,7 @@ export interface DownloadSnapshotOptions {
installPath?: string;
log?: ToolingLog;
useCached?: boolean;
resources?: string[];
}
export interface InstallSnapshotOptions extends DownloadSnapshotOptions {
@ -42,4 +44,5 @@ export interface InstallArchiveOptions {
esArgs?: string[];
/** Disable creating a temp directory, allowing ES to write to OS's /tmp directory */
disableEsTmpDir?: boolean;
resources?: string[];
}

View file

@ -311,6 +311,7 @@ describe('#installArchive()', () => {
esArgs: ['foo=true'],
log,
disableEsTmpDir: true,
resources: ['path/to/resource'],
};
const cluster = new Cluster({ log });
await cluster.installArchive('bar', options);

View file

@ -43,11 +43,11 @@ export function extractConfigFiles(
return localConfig;
}
function isFile(dest = '') {
export function isFile(dest = '') {
return fs.existsSync(dest) && fs.statSync(dest).isFile();
}
function copyFileSync(src: string, dest: string) {
export function copyFileSync(src: string, dest: string) {
const destPath = path.dirname(dest);
if (!fs.existsSync(destPath)) {

View file

@ -10,7 +10,7 @@ export { cache } from './cache';
export { log } from './log';
export { parseEsLog } from './parse_es_log';
export { findMostRecentlyChanged } from './find_most_recently_changed';
export { extractConfigFiles } from './extract_config_files';
export { extractConfigFiles, isFile, copyFileSync } from './extract_config_files';
// @ts-expect-error not typed yet
export { NativeRealm, SYSTEM_INDICES_SUPERUSER } from './native_realm';
export { buildSnapshot } from './build_snapshot';

View file

@ -6,20 +6,12 @@
* Side Public License, v 1.
*/
import fs from 'fs';
import { type Config } from '@kbn/test';
import { ToolingLog } from '@kbn/tooling-log';
import { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-utils';
import { KibanaServer } from '../..';
import { ServerlessAuthProvider } from './serverless/auth_provider';
import { StatefulAuthProvider } from './stateful/auth_provider';
import { createRole, createRoleMapping } from './stateful/create_role_mapping';
const STATEFUL_ADMIN_ROLE_MAPPING_PATH = './stateful/admin_mapping';
export interface AuthProvider {
getSupportedRoleDescriptors(): any;
getSupportedRoleDescriptors(): Record<string, unknown>;
getDefaultRole(): string;
getRolesDefinitionPath(): string;
getCommonRequestHeader(): { [key: string]: string };
@ -28,26 +20,10 @@ export interface AuthProvider {
export interface AuthProviderProps {
config: Config;
kibanaServer: KibanaServer;
log: ToolingLog;
}
export const getAuthProvider = async (props: AuthProviderProps) => {
const { config, log, kibanaServer } = props;
export const getAuthProvider = (props: AuthProviderProps) => {
const { config } = props;
const isServerless = !!props.config.get('serverless');
if (isServerless) {
return new ServerlessAuthProvider(config);
}
const provider = new StatefulAuthProvider();
// TODO: Move it to @kbn-es package, so that roles and its mapping are created before FTR services loading starts.
// 'viewer' and 'editor' roles are available by default, but we have to create 'admin' role
const adminRoleMapping = JSON.parse(
fs.readFileSync(require.resolve(STATEFUL_ADMIN_ROLE_MAPPING_PATH), 'utf8')
);
await createRole({ roleName: 'admin', roleMapping: adminRoleMapping, kibanaServer, log });
const roles = Object.keys(provider.getSupportedRoleDescriptors());
// Creating roles mapping for mock-idp
await createRoleMapping({ name: MOCK_IDP_REALM_NAME, roles, config, log });
return provider;
return isServerless ? new ServerlessAuthProvider(config) : new StatefulAuthProvider();
};

View file

@ -20,14 +20,13 @@ export interface RoleCredentials {
cookieHeader: { Cookie: string };
}
export async function SamlAuthProvider({ getService }: FtrProviderContext) {
export function SamlAuthProvider({ getService }: FtrProviderContext) {
const config = getService('config');
const log = getService('log');
const kibanaServer = getService('kibanaServer');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const isCloud = !!process.env.TEST_CLOUD;
const authRoleProvider = await getAuthProvider({ config, kibanaServer, log });
const authRoleProvider = getAuthProvider({ config });
const supportedRoleDescriptors = authRoleProvider.getSupportedRoleDescriptors();
const supportedRoles = Object.keys(supportedRoleDescriptors);

View file

@ -49,7 +49,7 @@ export class ServerlessAuthProvider implements AuthProvider {
this.rolesDefinitionPath = resolve(SERVERLESS_ROLES_ROOT_PATH, this.projectType, 'roles.yml');
}
getSupportedRoleDescriptors(): any {
getSupportedRoleDescriptors(): Record<string, unknown> {
return readRolesDescriptorsFromResource(this.rolesDefinitionPath);
}
getDefaultRole(): string {

View file

@ -1,46 +0,0 @@
{
"kibana":[
{
"base":[
"all"
],
"feature":{
},
"spaces":[
"*"
]
}
],
"elasticsearch":{
"cluster":[
"all"
],
"indices":[
{
"names":[
"*"
],
"privileges":[
"all"
],
"allow_restricted_indices":false
},
{
"names":[
"*"
],
"privileges":[
"monitor",
"read",
"read_cross_cluster",
"view_index_metadata"
],
"allow_restricted_indices":true
}
],
"run_as":[
]
}
}

View file

@ -17,7 +17,7 @@ import {
export class StatefulAuthProvider implements AuthProvider {
private readonly rolesDefinitionPath = resolve(REPO_ROOT, STATEFUL_ROLES_ROOT_PATH, 'roles.yml');
getSupportedRoleDescriptors(): any {
getSupportedRoleDescriptors(): Record<string, unknown> {
return readRolesDescriptorsFromResource(this.rolesDefinitionPath);
}
getDefaultRole() {

View file

@ -1,52 +0,0 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { Config, createEsClientForFtrConfig } from '@kbn/test';
import { ToolingLog } from '@kbn/tooling-log';
import { KibanaServer } from '../../..';
export interface CreateRoleProps {
roleName: string;
roleMapping: string[];
kibanaServer: KibanaServer;
log: ToolingLog;
}
export interface CreateRoleMappingProps {
name: string;
roles: string[];
config: Config;
log: ToolingLog;
}
export async function createRole(props: CreateRoleProps) {
const { roleName, roleMapping, kibanaServer, log } = props;
log.debug(`Adding a role: ${roleName}`);
const { status, statusText } = await kibanaServer.request({
path: `/api/security/role/${roleName}`,
method: 'PUT',
body: roleMapping,
retries: 0,
});
if (status !== 204) {
throw new Error(`Expected status code of 204, received ${status} ${statusText}`);
}
}
export async function createRoleMapping(props: CreateRoleMappingProps) {
const { name, roles, config, log } = props;
log.debug(`Creating a role mapping: {realm.name: ${name}, roles: ${roles}}`);
const esClient = createEsClientForFtrConfig(config);
await esClient.security.putRoleMapping({
name,
roles,
enabled: true,
// @ts-ignore
rules: { field: { 'realm.name': name } },
});
}

View file

@ -18,7 +18,6 @@
"@kbn/expect",
"@kbn/repo-info",
"@kbn/es",
"@kbn/mock-idp-utils"
],
"exclude": [
"target/**/*",

View file

@ -202,6 +202,7 @@ export function createTestEsCluster<
license,
basePath,
esArgs,
resources: files,
};
return new (class TestCluster {
@ -297,7 +298,7 @@ export function createTestEsCluster<
// If we have multiple nodes, we shouldn't try setting up the native realm
// right away or wait for ES to be green, the cluster isn't ready. So we only
// set it up after the last node is started.
skipNativeRealmSetup: this.nodes.length > 1 && i < this.nodes.length - 1,
skipSecuritySetup: this.nodes.length > 1 && i < this.nodes.length - 1,
skipReadyCheck: this.nodes.length > 1 && i < this.nodes.length - 1,
onEarlyExit,
writeLogsToPath,

View file

@ -18,6 +18,10 @@ import {
systemIndicesSuperuser,
FtrConfigProviderContext,
} from '@kbn/test';
import path from 'path';
// @ts-expect-error we have to check types with "allowJs: false" for now, causing this import to fail
import { REPO_ROOT } from '@kbn/repo-info';
import { STATEFUL_ROLES_ROOT_PATH } from '@kbn/es';
import { DeploymentAgnosticCommonServices, services } from '../services';
interface CreateTestConfigOptions<T extends DeploymentAgnosticCommonServices> {
@ -85,6 +89,10 @@ export function createStatefulTestConfig<T extends DeploymentAgnosticCommonServi
`xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.name=${MOCK_IDP_ATTRIBUTE_NAME}`,
`xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.mail=${MOCK_IDP_ATTRIBUTE_EMAIL}`,
],
files: [
// Passing the roles that are equivalent to the ones we have in serverless
path.resolve(REPO_ROOT, STATEFUL_ROLES_ROOT_PATH, 'roles.yml'),
],
},
kbnTestServer: {

View file

@ -32,8 +32,6 @@ export function SecuritySolutionServerlessUtils({
});
async function invalidateApiKey(credentials: RoleCredentials) {
// load service to call it outside mocha context
await svlUserManager.init();
await svlUserManager.invalidateM2mApiKeyWithRoleScope(credentials);
}
@ -55,8 +53,6 @@ export function SecuritySolutionServerlessUtils({
const createSuperTest = async (role = 'admin') => {
cleanCredentials(role);
// load service to call it outside mocha context
await svlUserManager.init();
const credentials = await svlUserManager.createM2mApiKeyWithRoleScope(role);
rolesCredentials.set(role, credentials);
@ -66,8 +62,6 @@ export function SecuritySolutionServerlessUtils({
return {
getUsername: async (role = 'admin') => {
// load service to call it outside mocha context
await svlUserManager.init();
const { username } = await svlUserManager.getUserData(role);
return username;