Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions x-pack/plugins/cloud/public/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,10 @@ describe('Cloud Plugin', () => {
expect(hashId1).not.toEqual(hashId2);
});

test('user hash does not include cloudId when authenticated via Cloud SAML', async () => {
test('user hash does not include cloudId when user is an Elastic Cloud user', async () => {
const { coreSetup } = await setupPlugin({
config: { id: 'cloudDeploymentId' },
currentUserProps: {
username,
authentication_realm: { type: 'saml', name: 'cloud-saml-kibana' },
},
currentUserProps: { username, elastic_cloud_user: true },
});

expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled();
Expand Down
8 changes: 2 additions & 6 deletions x-pack/plugins/cloud/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,8 @@ export class CloudPlugin implements Plugin<CloudSetup> {
name: 'cloud_user_id',
context$: from(security.authc.getCurrentUser()).pipe(
map((user) => {
if (
getIsCloudEnabled(cloudId) &&
user.authentication_realm?.type === 'saml' &&
user.authentication_realm?.name === 'cloud-saml-kibana'
) {
// If authenticated via Cloud SAML, use the SAML username as the user ID
if (user.elastic_cloud_user) {
// If authenticated via Elastic Cloud SSO, use the username as the user ID
return user.username;
}

Expand Down
70 changes: 70 additions & 0 deletions x-pack/plugins/cloud/server/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { coreMock } from '@kbn/core/server/mocks';
import { CloudPlugin } from './plugin';
import { config } from './config';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks';

describe('Cloud Plugin', () => {
describe('#setup', () => {
describe('setupSecurity', () => {
it('properly handles missing optional Security dependency if Cloud ID is NOT set.', async () => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({}))
);

expect(() =>
plugin.setup(coreMock.createSetup(), {
usageCollection: usageCollectionPluginMock.createSetupContract(),
})
).not.toThrow();
});

it('properly handles missing optional Security dependency if Cloud ID is set.', async () => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({ id: 'my-cloud' }))
);

expect(() =>
plugin.setup(coreMock.createSetup(), {
usageCollection: usageCollectionPluginMock.createSetupContract(),
})
).not.toThrow();
});

it('does not notify Security plugin about Cloud environment if Cloud ID is NOT set.', async () => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({}))
);

const securityDependencyMock = securityMock.createSetup();
plugin.setup(coreMock.createSetup(), {
security: securityDependencyMock,
usageCollection: usageCollectionPluginMock.createSetupContract(),
});

expect(securityDependencyMock.setIsElasticCloudDeployment).not.toHaveBeenCalled();
});

it('properly notifies Security plugin about Cloud environment if Cloud ID is set.', async () => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({ id: 'my-cloud' }))
);

const securityDependencyMock = securityMock.createSetup();
plugin.setup(coreMock.createSetup(), {
security: securityDependencyMock,
usageCollection: usageCollectionPluginMock.createSetupContract(),
});

expect(securityDependencyMock.setIsElasticCloudDeployment).toHaveBeenCalledTimes(1);
});
});
});
});
4 changes: 4 additions & 0 deletions x-pack/plugins/cloud/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export class CloudPlugin implements Plugin<CloudSetup> {
registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id);
registerCloudUsageCollector(usageCollection, { isCloudEnabled });

if (isCloudEnabled) {
security?.setIsElasticCloudDeployment();
}
Comment on lines +53 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT/optional: Shame on us, we don't yet have a test file for x-pack/plugins/cloud/server/plugin.ts, but it would make sense to add one to test this call/behavior

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point, I'll add one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added few basic tests in c26c503


if (this.config.full_story.enabled) {
registerFullstoryRoute({
httpResources: core.http.resources,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function mockAuthenticatedUser(user: MockAuthenticatedUserProps = {}) {
lookup_realm: { name: 'native1', type: 'native' },
authentication_provider: { type: 'basic', name: 'basic1' },
authentication_type: 'realm',
elastic_cloud_user: false,
metadata: { _reserved: false },
...user,
};
Expand Down
5 changes: 5 additions & 0 deletions x-pack/plugins/security/common/model/authenticated_user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export interface AuthenticatedUser extends User {
* @example "realm" | "api_key" | "token" | "anonymous" | "internal"
*/
authentication_type: string;

/**
* Indicates whether user is authenticated via Elastic Cloud built-in SAML realm.
*/
elastic_cloud_user: boolean;
}

export function isUserAnonymous(user: Pick<AuthenticatedUser, 'authentication_provider'>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ describe('SecurityNavControl', () => {
"type": "native",
},
"authentication_type": "realm",
"elastic_cloud_user": false,
"email": "email",
"enabled": true,
"full_name": "full name",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe('AuthenticationService', () => {
session: jest.Mocked<PublicMethodsOf<Session>>;
applicationName: 'kibana-.kibana';
kibanaFeatures: [];
isElasticCloudDeployment: jest.Mock;
};
beforeEach(() => {
logger = loggingSystemMock.createLogger();
Expand Down Expand Up @@ -115,6 +116,7 @@ describe('AuthenticationService', () => {
userProfileService: userProfileServiceMock.createStart(),
applicationName: 'kibana-.kibana',
kibanaFeatures: [],
isElasticCloudDeployment: jest.fn().mockReturnValue(false),
};
(mockStartAuthenticationParams.http.basePath.get as jest.Mock).mockImplementation(
() => mockStartAuthenticationParams.http.basePath.serverBasePath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ interface AuthenticationServiceStartParams {
loggers: LoggerFactory;
applicationName: string;
kibanaFeatures: KibanaFeature[];
isElasticCloudDeployment: () => boolean;
}

export interface InternalAuthenticationServiceStart extends AuthenticationServiceStart {
Expand Down Expand Up @@ -304,6 +305,7 @@ export class AuthenticationService {
session,
applicationName,
kibanaFeatures,
isElasticCloudDeployment,
}: AuthenticationServiceStartParams): InternalAuthenticationServiceStart {
const apiKeys = new APIKeys({
clusterClient,
Expand Down Expand Up @@ -340,6 +342,7 @@ export class AuthenticationService {
getServerBaseURL,
license: this.license,
session,
isElasticCloudDeployment,
});

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function getMockOptions({
session: sessionMock.create(),
featureUsageService: securityFeatureUsageServiceMock.createStartContract(),
userProfileService: userProfileServiceMock.createStart(),
isElasticCloudDeployment: jest.fn().mockReturnValue(false),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface AuthenticatorOptions {
clusterClient: IClusterClient;
session: PublicMethodsOf<Session>;
getServerBaseURL: () => string;
isElasticCloudDeployment: () => boolean;
}

/** @internal */
Expand Down Expand Up @@ -232,6 +233,7 @@ export class Authenticator {
logger: this.options.loggers.get('tokens'),
}),
getServerBaseURL: this.options.getServerBaseURL,
isElasticCloudDeployment: this.options.isElasticCloudDeployment,
};

this.providers = new Map(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ export function mockAuthenticationProviderOptions(options?: { name: string }) {
urls: {
loggedOut: jest.fn().mockReturnValue('/mock-server-basepath/security/logged_out'),
},
isElasticCloudDeployment: jest.fn().mockReturnValue(false),
};
}
10 changes: 10 additions & 0 deletions x-pack/plugins/security/server/authentication/providers/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,19 @@ export interface AuthenticationProviderOptions {
urls: {
loggedOut: (request: KibanaRequest) => string;
};
isElasticCloudDeployment: () => boolean;
}

/**
* Represents available provider specific options.
*/
export type AuthenticationProviderSpecificOptions = Record<string, unknown>;

/**
* Name of the Elastic Cloud built-in SSO realm.
*/
export const ELASTIC_CLOUD_SSO_REALM_NAME = 'cloud-saml-kibana';

/**
* Base class that all authentication providers should extend.
*/
Expand Down Expand Up @@ -133,6 +139,10 @@ export abstract class BaseAuthenticationProvider {
return deepFreeze({
...authenticationInfo,
authentication_provider: { type: this.type, name: this.options.name },
elastic_cloud_user:
this.options.isElasticCloudDeployment() &&
authenticationInfo.authentication_realm.type === 'saml' &&
authenticationInfo.authentication_realm.name === ELASTIC_CLOUD_SSO_REALM_NAME,
} as AuthenticatedUser);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.
import { securityMock } from '../../mocks';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { ELASTIC_CLOUD_SSO_REALM_NAME } from './base';
import type { MockAuthenticationProviderOptions } from './base.mock';
import { mockAuthenticationProviderOptions } from './base.mock';
import { SAMLAuthenticationProvider, SAMLLogin } from './saml';
Expand Down Expand Up @@ -366,6 +367,43 @@ describe('SAMLAuthenticationProvider', () => {
);
});

it('recognizes Elastic Cloud users.', async () => {
const nonElasticCloudUser = mockAuthenticatedUser({
authentication_provider: { type: 'saml', name: 'saml' },
authentication_realm: { type: 'saml', name: 'random-saml' },
});
const elasticCloudUser = mockAuthenticatedUser({
authentication_provider: { type: 'saml', name: 'saml' },
authentication_realm: { type: 'saml', name: ELASTIC_CLOUD_SSO_REALM_NAME },
});

// The only case when user should be recognized as Elastic Cloud user: Kibana is running inside Cloud
// deployment and user is authenticated with SAML realm of the predefined name.
for (const [authentication, isElasticCloudDeployment, isElasticCloudUser] of [
[nonElasticCloudUser, false, false],
[nonElasticCloudUser, true, false],
[elasticCloudUser, false, false],
[elasticCloudUser, true, true],
]) {
mockOptions.client.asInternalUser.transport.request.mockResolvedValue({
username: 'user',
access_token: 'valid-token',
refresh_token: 'valid-refresh-token',
realm: 'test-realm',
authentication,
});

mockOptions.isElasticCloudDeployment.mockReturnValue(isElasticCloudDeployment);

const loginResult = await provider.login(
httpServerMock.createKibanaRequest({ headers: {} }),
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }
);

expect(loginResult.user?.elastic_cloud_user).toBe(isElasticCloudUser);
}
});

it('redirects to the home page if `relayState` includes external URL', async () => {
await expect(
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
Expand Down
5 changes: 4 additions & 1 deletion x-pack/plugins/security/server/elasticsearch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

import type { AuthenticatedUser } from '../../common/model';

export type AuthenticationInfo = Omit<AuthenticatedUser, 'authentication_provider'>;
export type AuthenticationInfo = Omit<
AuthenticatedUser,
'authentication_provider' | 'elastic_cloud_user'
>;
export type {
ElasticsearchServiceStart,
OnlineStatusRetryScheduler,
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/security/server/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function createSetupMock() {
privilegeDeprecationsService: {
getKibanaRolesByFeatureId: jest.fn(),
},
setIsElasticCloudDeployment: jest.fn(),
};
}

Expand Down
9 changes: 9 additions & 0 deletions x-pack/plugins/security/server/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,18 @@ describe('Security Plugin', () => {
"privilegeDeprecationsService": Object {
"getKibanaRolesByFeatureId": [Function],
},
"setIsElasticCloudDeployment": [Function],
}
`);
});

it('#setIsElasticCloudDeployment cannot be called twice', () => {
const { setIsElasticCloudDeployment } = plugin.setup(mockCoreSetup, mockSetupDependencies);
setIsElasticCloudDeployment();
expect(() => setIsElasticCloudDeployment()).toThrowErrorMatchingInlineSnapshot(
`"The Elastic Cloud deployment flag has been set already!"`
);
});
});

describe('start()', () => {
Expand Down
23 changes: 23 additions & 0 deletions x-pack/plugins/security/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ export interface SecurityPluginSetup {
* Exposes services to access kibana roles per feature id with the GetDeprecationsContext
*/
privilegeDeprecationsService: PrivilegeDeprecationsService;

/**
* Sets the flag to indicate that Kibana is running inside an Elastic Cloud deployment. This flag is supposed to be
* set by the Cloud plugin and can be only once.
*/
setIsElasticCloudDeployment: () => void;
}

/**
Expand Down Expand Up @@ -199,6 +205,21 @@ export class SecurityPlugin
return this.userProfileStart;
};

/**
* Indicates whether Kibana is running inside an Elastic Cloud deployment. Since circular plugin dependencies are
* forbidden, this flag is supposed to be set by the Cloud plugin that already depends on the Security plugin.
* @private
*/
private isElasticCloudDeployment?: boolean;
private readonly getIsElasticCloudDeployment = () => this.isElasticCloudDeployment === true;
private readonly setIsElasticCloudDeployment = () => {
if (this.isElasticCloudDeployment !== undefined) {
throw new Error(`The Elastic Cloud deployment flag has been set already!`);
}

this.isElasticCloudDeployment = true;
};

constructor(private readonly initializerContext: PluginInitializerContext) {
this.logger = this.initializerContext.logger.get();

Expand Down Expand Up @@ -348,6 +369,7 @@ export class SecurityPlugin
license,
logger: this.logger.get('deprecations'),
}),
setIsElasticCloudDeployment: this.setIsElasticCloudDeployment,
});
}

Expand Down Expand Up @@ -386,6 +408,7 @@ export class SecurityPlugin
session,
applicationName: this.authorizationSetup!.applicationName,
kibanaFeatures: features.getKibanaFeatures(),
isElasticCloudDeployment: this.getIsElasticCloudDeployment,
});

this.authorizationService.start({
Expand Down
Loading