Skip to content

Commit

Permalink
[FTR] support custom native roles in serverless tests (#194677)
Browse files Browse the repository at this point in the history
## Summary

This PR updates FTR services to support authentication with custom
native role. Few notes:
- for compatibility with MKI we reserve **"customRole"** as a custom
role name used in tests
- test user is **automatically assigned** to this role, but before login
in browser/ generating cookie header or API key in each test suite
**role privileges must me updated according test scenario**

How to test:
I added a new test file for Search project:
`x-pack/test_serverless/functional/test_suites/search/custom_role_access.ts`

It can be run locally with:
```
 node scripts/functional_tests --config=x-pack/test_serverless/functional/test_suites/search/config.ts --grep "With custom role"
```

FTR UI test example:

```ts
// First set privileges for custom role
await samlAuth.setCustomRole({
        elasticsearch: {
          indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }],
        },
        kibana: [
          {
            feature: {
              discover: ['read'],
            },
            spaces: ['*'],
          },
        ],
      });
    });

// Then you can login in browser as a user with newly defined privileges
await pageObjects.svlCommonPage.loginWithCustomRole();
```

FTR api_integration test example:

```ts
// First set privileges for custom role
await samlAuth.setCustomRole({
        elasticsearch: {
          indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }],
        },
        kibana: [
          {
            feature: {
              discover: ['read'],
            },
            spaces: ['*'],
          },
        ],
      });
    });
// Then you can generate an API key with newly defined privileges
const roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('customRole');
// Don't forget to invalidate the API key in the end
await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc);
```
  • Loading branch information
dmlemeshko authored Oct 11, 2024
1 parent 3252d04 commit f00ac7a
Show file tree
Hide file tree
Showing 14 changed files with 321 additions and 42 deletions.
1 change: 1 addition & 0 deletions packages/kbn-ftr-common-functional-services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type {
InternalRequestHeader,
RoleCredentials,
CookieCredentials,
KibanaRoleDescriptors,
} from './services/saml_auth';

import { SamlAuthProvider } from './services/saml_auth/saml_auth_provider';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { ServerlessAuthProvider } from './serverless/auth_provider';
import { StatefulAuthProvider } from './stateful/auth_provider';

export interface AuthProvider {
getSupportedRoleDescriptors(): Record<string, unknown>;
getSupportedRoleDescriptors(): Map<string, any>;
getDefaultRole(): string;
isCustomRoleEnabled(): boolean;
getCustomRole(): string;
getRolesDefinitionPath(): string;
getCommonRequestHeader(): { [key: string]: string };
getInternalRequestHeader(): { [key: string]: string };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
*/

export { SamlAuthProvider } from './saml_auth_provider';
export type { RoleCredentials, CookieCredentials } from './saml_auth_provider';
export type {
RoleCredentials,
CookieCredentials,
KibanaRoleDescriptors,
} from './saml_auth_provider';
export type { InternalRequestHeader } from './default_request_headers';
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export interface CookieCredentials {
[header: string]: string;
}

export interface KibanaRoleDescriptors {
kibana: any;
elasticsearch?: any;
}

const throwIfRoleNotSet = (role: string, customRole: string, roleDescriptors: Map<string, any>) => {
if (role === customRole && !roleDescriptors.has(customRole)) {
throw new Error(
`Set privileges for '${customRole}' using 'samlAuth.setCustomRole' before authentication.`
);
}
};

export function SamlAuthProvider({ getService }: FtrProviderContext) {
const config = getService('config');
const log = getService('log');
Expand All @@ -35,9 +48,8 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) {

const authRoleProvider = getAuthProvider({ config });
const supportedRoleDescriptors = authRoleProvider.getSupportedRoleDescriptors();
const supportedRoles = Object.keys(supportedRoleDescriptors);

const customRolesFileName: string | undefined = process.env.ROLES_FILENAME_OVERRIDE;
const supportedRoles = Array.from(supportedRoleDescriptors.keys());
const customRolesFileName = process.env.ROLES_FILENAME_OVERRIDE;
const cloudUsersFilePath = resolve(REPO_ROOT, '.ftr', customRolesFileName ?? 'role_users.json');

// Sharing the instance within FTR config run means cookies are persistent for each role between tests.
Expand All @@ -61,55 +73,78 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) {
const DEFAULT_ROLE = authRoleProvider.getDefaultRole();
const COMMON_REQUEST_HEADERS = authRoleProvider.getCommonRequestHeader();
const INTERNAL_REQUEST_HEADERS = authRoleProvider.getInternalRequestHeader();
const CUSTOM_ROLE = authRoleProvider.getCustomRole();
const isCustomRoleEnabled = authRoleProvider.isCustomRoleEnabled();

const getAdminCredentials = async () => {
return await sessionManager.getApiCredentialsForRole('admin');
};

const createApiKeyPayload = (role: string, roleDescriptors: any) => {
return {
name: `myTestApiKey_${role}`,
metadata: {},
...(role === CUSTOM_ROLE
? { kibana_role_descriptors: roleDescriptors }
: { role_descriptors: roleDescriptors }),
};
};

return {
async getInteractiveUserSessionCookieWithRoleScope(role: string) {
// Custom role has no descriptors by default, check if it was added before authentication
throwIfRoleNotSet(role, CUSTOM_ROLE, supportedRoleDescriptors);
return sessionManager.getInteractiveUserSessionCookieWithRoleScope(role);
},

async getM2MApiCookieCredentialsWithRoleScope(role: string): 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);
},

async getEmail(role: string) {
return sessionManager.getEmail(role);
},

async getUserData(role: string) {
return sessionManager.getUserData(role);
},

async createM2mApiKeyWithDefaultRoleScope() {
log.debug(`Creating api key for default role: [${this.DEFAULT_ROLE}]`);
return this.createM2mApiKeyWithRoleScope(this.DEFAULT_ROLE);
log.debug(`Creating API key for default role: [${DEFAULT_ROLE}]`);
return this.createM2mApiKeyWithRoleScope(DEFAULT_ROLE);
},

async createM2mApiKeyWithRoleScope(role: string): Promise<RoleCredentials> {
// Get admin credentials in order to create the API key
const adminCookieHeader = await this.getM2MApiCookieCredentialsWithRoleScope('admin');

// Get the role descrtiptor for the role
const adminCookieHeader = await getAdminCredentials();
let roleDescriptors = {};

if (role !== 'admin') {
const roleDescriptor = supportedRoleDescriptors[role];
if (role === CUSTOM_ROLE && !isCustomRoleEnabled) {
throw new Error(`Custom roles are not supported for the current deployment`);
}
const roleDescriptor = supportedRoleDescriptors.get(role);
if (!roleDescriptor) {
throw new Error(`Cannot create API key for non-existent role "${role}"`);
throw new Error(
role === CUSTOM_ROLE
? `Before creating API key for '${CUSTOM_ROLE}', use 'samlAuth.setCustomRole' to set the role privileges`
: `Cannot create API key for non-existent role "${role}"`
);
}
log.debug(
`Creating api key for ${role} role with the following privileges ${JSON.stringify(
roleDescriptor
)}`
`Creating API key for ${role} with privileges: ${JSON.stringify(roleDescriptor)}`
);
roleDescriptors = {
[role]: roleDescriptor,
};
roleDescriptors = { [role]: roleDescriptor };
}

const payload = createApiKeyPayload(role, roleDescriptors);
const response = await supertestWithoutAuth
.post('/internal/security/api_key')
.set(INTERNAL_REQUEST_HEADERS)
.set(adminCookieHeader)
.send({
name: 'myTestApiKey',
metadata: {},
role_descriptors: roleDescriptors,
});
.send(payload);

if (response.status !== 200) {
throw new Error(
Expand All @@ -120,38 +155,59 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) {
const apiKey = response.body;
const apiKeyHeader = { Authorization: 'ApiKey ' + apiKey.encoded };

log.debug(`Created api key for role: [${role}]`);
log.debug(`Created API key for role: [${role}]`);
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: [
{
id: roleCredentials.apiKey.id,
name: roleCredentials.apiKey.name,
},
],
isAdmin: true,
};
const adminCookieHeader = await getAdminCredentials();

const { status } = await supertestWithoutAuth
.post('/internal/security/api_key/invalidate')
.set(INTERNAL_REQUEST_HEADERS)
.set(adminCookieHeader)
.send(requestBody);
.send({
apiKeys: [{ id: roleCredentials.apiKey.id, name: roleCredentials.apiKey.name }],
isAdmin: true,
});

expect(status).to.be(200);
},

async setCustomRole(descriptors: KibanaRoleDescriptors) {
if (!isCustomRoleEnabled) {
throw new Error(`Custom roles are not supported for the current deployment`);
}
log.debug(`Updating role ${CUSTOM_ROLE}`);
const adminCookieHeader = await getAdminCredentials();

const customRoleDescriptors = {
kibana: descriptors.kibana,
elasticsearch: descriptors.elasticsearch ?? [],
};

const { status } = await supertestWithoutAuth
.put(`/api/security/role/${CUSTOM_ROLE}`)
.set(INTERNAL_REQUEST_HEADERS)
.set(adminCookieHeader)
.send(customRoleDescriptors);

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

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

getCommonRequestHeader() {
return COMMON_REQUEST_HEADERS;
},

getInternalRequestHeader(): InternalRequestHeader {
return INTERNAL_REQUEST_HEADERS;
},

DEFAULT_ROLE,
CUSTOM_ROLE,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const projectDefaultRoles = new Map<string, Role>([
['oblt', 'editor'],
]);

const projectTypesWithCustomRolesEnabled = ['es', 'security'];

const getDefaultServerlessRole = (projectType: string) => {
if (projectDefaultRoles.has(projectType)) {
return projectDefaultRoles.get(projectType)!;
Expand All @@ -50,18 +52,39 @@ export class ServerlessAuthProvider implements AuthProvider {
this.rolesDefinitionPath = resolve(SERVERLESS_ROLES_ROOT_PATH, this.projectType, 'roles.yml');
}

getSupportedRoleDescriptors(): Record<string, unknown> {
return readRolesDescriptorsFromResource(this.rolesDefinitionPath) as Record<string, unknown>;
getSupportedRoleDescriptors() {
const roleDescriptors = new Map<string, any>(
Object.entries(
readRolesDescriptorsFromResource(this.rolesDefinitionPath) as Record<string, unknown>
)
);
// Adding custom role to the map without privileges, so it can be later updated and used in the tests
if (this.isCustomRoleEnabled()) {
roleDescriptors.set(this.getCustomRole(), null);
}
return roleDescriptors;
}

getDefaultRole(): string {
return getDefaultServerlessRole(this.projectType);
}

isCustomRoleEnabled() {
return projectTypesWithCustomRolesEnabled.includes(this.projectType);
}

getCustomRole() {
return 'customRole';
}

getRolesDefinitionPath(): string {
return this.rolesDefinitionPath;
}

getCommonRequestHeader() {
return COMMON_REQUEST_HEADERS;
}

getInternalRequestHeader() {
return getServerlessInternalRequestHeaders();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,31 @@ import {

export class StatefulAuthProvider implements AuthProvider {
private readonly rolesDefinitionPath = resolve(REPO_ROOT, STATEFUL_ROLES_ROOT_PATH, 'roles.yml');
getSupportedRoleDescriptors(): Record<string, unknown> {
return readRolesDescriptorsFromResource(this.rolesDefinitionPath) as Record<string, unknown>;

getSupportedRoleDescriptors() {
const roleDescriptors = new Map<string, any>(
Object.entries(
readRolesDescriptorsFromResource(this.rolesDefinitionPath) as Record<string, unknown>
)
);
// no privileges set by default
roleDescriptors.set(this.getCustomRole(), null);

return roleDescriptors;
}

getDefaultRole() {
return 'editor';
}

isCustomRoleEnabled() {
return true;
}

getCustomRole() {
return 'customRole';
}

getRolesDefinitionPath() {
return this.rolesDefinitionPath;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ export function createServerlessTestConfig<T extends DeploymentAgnosticCommonSer
...svlSharedConfig.get('esTestCluster'),
serverArgs: [
...svlSharedConfig.get('esTestCluster.serverArgs'),
// custom native roles are enabled only for search and security projects
...(options.serverlessProject !== 'oblt'
? ['xpack.security.authc.native_roles.enabled=true']
: []),
...esServerArgsFromController[options.serverlessProject],
],
},
Expand All @@ -109,6 +113,10 @@ export function createServerlessTestConfig<T extends DeploymentAgnosticCommonSer
...svlSharedConfig.get('kbnTestServer.serverArgs'),
...kbnServerArgsFromController[options.serverlessProject],
`--serverless=${options.serverlessProject}`,
// custom native roles are enabled only for search and security projects
...(options.serverlessProject !== 'oblt'
? ['--xpack.security.roleManagementEnabled=true']
: []),
],
},
testFiles: options.testFiles,
Expand Down
53 changes: 53 additions & 0 deletions x-pack/test_serverless/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,59 @@ describe("my internal APIs test suite", async function() {
});
```

#### Testing with custom roles

With custom native roles now enabled for the Security and Search projects on MKI, the FTR supports
defining and authenticating with custom roles in both UI functional tests and API integration tests.

For compatibility with MKI, the role name `customRole` is reserved for use in tests. The test user is automatically assigned to this role, but before logging in via the browser, generating a cookie header, or creating an API key in each test suite, the role’s privileges must be updated.

Note: We are still working on a solution to run these tests against MKI. In the meantime, please tag the suite with `skipMKI`.

FTR UI test example:
```
// First, set privileges for the custom role
await samlAuth.setCustomRole({
elasticsearch: {
indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }],
},
kibana: [
{
feature: {
discover: ['read'],
},
spaces: ['*'],
},
],
});
// Then, log in via the browser as a user with the newly defined privileges
await pageObjects.svlCommonPage.loginWithCustomRole();
```

FTR api_integration test example:
```
// First, set privileges for the custom role
await samlAuth.setCustomRole({
elasticsearch: {
indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }],
},
kibana: [
{
feature: {
discover: ['read'],
},
spaces: ['*'],
},
],
});
// Then, generate an API key with the newly defined privileges
const roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('customRole');
// Remember to invalidate the API key after use
await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc);
```

### Testing with feature flags

**tl;dr:** Tests specific to functionality behind a feature flag need special
Expand Down
Loading

0 comments on commit f00ac7a

Please sign in to comment.