Introduce suggest user profile functionality. (#135063)

This commit is contained in:
Aleh Zasypkin 2022-07-21 11:18:21 +02:00 committed by GitHub
parent 10cd177456
commit ac15ee4540
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 2329 additions and 415 deletions

View file

@ -240,6 +240,7 @@ enabled:
- x-pack/test/security_api_integration/session_invalidate.config.ts
- x-pack/test/security_api_integration/session_lifespan.config.ts
- x-pack/test/security_api_integration/token.config.ts
- x-pack/test/security_api_integration/user_profiles.config.ts
- x-pack/test/security_functional/login_selector.config.ts
- x-pack/test/security_functional/oidc.config.ts
- x-pack/test/security_functional/saml.config.ts

View file

@ -8,7 +8,7 @@
export type { SecurityLicense, SecurityLicenseFeatures, LoginLayout } from './licensing';
export type {
AuthenticatedUser,
AuthenticatedUserProfile,
GetUserProfileResponse,
AuthenticationProvider,
PrivilegeDeprecationsService,
PrivilegeDeprecationsRolesByFeatureIdRequest,
@ -19,9 +19,12 @@ export type {
FeaturesPrivileges,
User,
UserProfile,
UserData,
UserAvatarData,
UserInfo,
UserProfileUserInfo,
UserProfileWithSecurity,
UserProfileData,
UserProfileLabels,
UserProfileAvatarData,
UserProfileUserInfoWithSecurity,
ApiKey,
UserRealm,
} from './model';

View file

@ -9,7 +9,13 @@
* Type and name tuple to identify provider used to authenticate user.
*/
export interface AuthenticationProvider {
/**
* Type of the Kibana authentication provider.
*/
type: string;
/**
* Name of the Kibana authentication provider (arbitrary string).
*/
name: string;
}

View file

@ -8,11 +8,14 @@
export type { ApiKey, ApiKeyToInvalidate, ApiKeyRoleDescriptors } from './api_key';
export type { User, EditUser } from './user';
export type {
AuthenticatedUserProfile,
GetUserProfileResponse,
UserProfile,
UserData,
UserInfo,
UserAvatarData,
UserProfileUserInfo,
UserProfileWithSecurity,
UserProfileData,
UserProfileLabels,
UserProfileUserInfoWithSecurity,
UserProfileAvatarData,
} from './user_profile';
export {
getUserAvatarColor,

View file

@ -5,6 +5,9 @@
* 2.0.
*/
/**
* A set of fields describing Kibana user.
*/
export interface User {
username: string;
email?: string;

View file

@ -5,25 +5,38 @@
* 2.0.
*/
import { mockAuthenticatedUser } from './authenticated_user.mock';
import type { AuthenticatedUserProfile } from './user_profile';
import type { UserProfile, UserProfileWithSecurity } from './user_profile';
function createUserProfileMock(userProfile: Partial<UserProfile> = {}) {
return {
uid: 'some-profile-uid',
enabled: true,
user: {
username: 'some-username',
email: 'some@email',
...userProfile.user,
},
data: {},
...userProfile,
};
}
export const userProfileMock = {
create: (userProfile: Partial<AuthenticatedUserProfile> = {}): AuthenticatedUserProfile => {
const user = mockAuthenticatedUser({
username: 'some-username',
roles: [],
enabled: true,
});
create: createUserProfileMock,
createWithSecurity: (
userProfileWithSecurity: Partial<UserProfileWithSecurity> = {}
): UserProfileWithSecurity => {
const userProfile = createUserProfileMock(userProfileWithSecurity);
return {
uid: 'some-profile-uid',
enabled: true,
user: {
...user,
active: true,
},
data: {},
labels: {},
...userProfile,
user: {
realm_domain: 'some-realm-domain',
realm_name: 'some-realm',
roles: [],
...userProfile.user,
},
};
},
};

View file

@ -7,35 +7,13 @@
import { VISUALIZATION_COLORS } from '@elastic/eui';
import type { User } from '..';
import type { AuthenticatedUser } from './authenticated_user';
import { getUserDisplayName } from './user';
/**
* User information returned in user profile.
* Describes basic properties stored in user profile.
*/
export interface UserInfo extends User {
active: boolean;
}
/**
* Avatar stored in user profile.
*/
export interface UserAvatarData {
initials?: string;
color?: string;
imageUrl?: string;
}
/**
* Placeholder for data stored in user profile.
*/
export type UserData = Record<string, unknown>;
/**
* Describes properties stored in user profile.
*/
export interface UserProfile<T extends UserData = UserData> {
export interface UserProfile<D extends UserProfileData = UserProfileData> {
/**
* Unique ID for of the user profile.
*/
@ -49,22 +27,111 @@ export interface UserProfile<T extends UserData = UserData> {
/**
* Information about the user that owns profile.
*/
user: UserInfo;
user: UserProfileUserInfo;
/**
* User specific data associated with the profile.
*/
data: T;
data: Partial<D>;
}
/**
* Basic user information returned in user profile.
*/
export interface UserProfileUserInfo {
/**
* Username of the user.
*/
username: string;
/**
* Optional email of the user.
*/
email?: string;
/**
* Optional full name of the user.
*/
full_name?: string;
/**
* Optional display name of the user.
*/
display_name?: string;
}
/**
* Placeholder for data stored in user profile.
*/
export type UserProfileData = Record<string, unknown>;
/**
* Type of the user profile labels structure (currently
*/
export type UserProfileLabels = Record<string, string>;
/**
* Avatar stored in user profile.
*/
export interface UserProfileAvatarData {
/**
* Optional initials (two letters) of the user to use as avatar if avatar picture isn't specified.
*/
initials?: string;
/**
* Background color of the avatar when initials are used.
*/
color?: string;
/**
* Base64 data URL for the user avatar image.
*/
imageUrl?: string;
}
/**
* Extended user information returned in user profile (both basic and security related properties).
*/
export interface UserProfileUserInfoWithSecurity extends UserProfileUserInfo {
/**
* List of the user roles.
*/
roles: readonly string[];
/**
* Name of the Elasticsearch security realm that was used to authenticate user.
*/
realm_name: string;
/**
* Optional name of the security domain that Elasticsearch security realm that was
* used to authenticate user resides in (if any).
*/
realm_domain?: string;
}
/**
* Describes all properties stored in user profile (both basic and security related properties).
*/
export interface UserProfileWithSecurity<
D extends UserProfileData = UserProfileData,
L extends UserProfileLabels = UserProfileLabels
> extends UserProfile<D> {
/**
* Information about the user that owns profile.
*/
user: UserProfileUserInfoWithSecurity;
/**
* User specific _searchable_ labels associated with the profile. Note that labels are considered
* security related field since it's going to be used to store user's space ID.
*/
labels: L;
}
/**
* User profile enriched with session information.
*/
export interface AuthenticatedUserProfile<T extends UserData = UserData> extends UserProfile<T> {
export interface GetUserProfileResponse<D extends UserProfileData = UserProfileData>
extends UserProfileWithSecurity<D> {
/**
* Information about the currently authenticated user that owns the profile.
*/
user: UserProfile['user'] & Pick<AuthenticatedUser, 'authentication_provider'>;
user: UserProfileWithSecurity['user'] & Pick<AuthenticatedUser, 'authentication_provider'>;
}
export const USER_AVATAR_FALLBACK_CODE_POINT = 97; // code point for lowercase "a"
@ -75,12 +142,12 @@ export const USER_AVATAR_MAX_INITIALS = 2;
* If a color is present on the user profile itself, then that is used.
* Otherwise, a color is provided from EUI's Visualization Colors based on the display name.
*
* @param {UserInfo} user User info
* @param {UserAvatarData} avatar User avatar
* @param {UserProfileUserInfoWithSecurity} user User info
* @param {UserProfileAvatarData} avatar User avatar
*/
export function getUserAvatarColor(
user: Pick<UserInfo, 'username' | 'full_name'>,
avatar?: UserAvatarData
user: Pick<UserProfileUserInfoWithSecurity, 'username' | 'full_name'>,
avatar?: UserProfileAvatarData
) {
if (avatar && avatar.color) {
return avatar.color;
@ -96,12 +163,12 @@ export function getUserAvatarColor(
* If initials are present on the user profile itself, then that is used.
* Otherwise, the initials are calculated based off the words in the display name, with a max length of 2 characters.
*
* @param {UserInfo} user User info
* @param {UserAvatarData} avatar User avatar
* @param {UserProfileUserInfoWithSecurity} user User info
* @param {UserProfileAvatarData} avatar User avatar
*/
export function getUserAvatarInitials(
user: Pick<UserInfo, 'username' | 'full_name'>,
avatar?: UserAvatarData
user: Pick<UserProfileUserInfoWithSecurity, 'username' | 'full_name'>,
avatar?: UserProfileAvatarData
) {
if (avatar && avatar.initials) {
return avatar.initials;

View file

@ -10,7 +10,7 @@ import React from 'react';
import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks';
import type { UserData } from '../../common';
import type { UserProfileData } from '../../common';
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
import { UserAPIClient } from '../management';
import { securityMock } from '../mocks';
@ -47,7 +47,7 @@ describe('<AccountManagementPage>', () => {
it('should render user profile form and set breadcrumbs', async () => {
const user = mockAuthenticatedUser();
const data: UserData = {};
const data: UserProfileData = {};
authc.getCurrentUser.mockResolvedValue(user);
coreStart.http.get.mockResolvedValue({ user, data });

View file

@ -13,7 +13,7 @@ import type { CoreStart } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { UserAvatarData } from '../../common';
import type { UserProfileAvatarData } from '../../common';
import { canUserHaveProfile } from '../../common/model';
import { useCurrentUser, useUserProfile } from '../components';
import { Breadcrumb } from '../components/breadcrumb';
@ -23,7 +23,7 @@ export const AccountManagementPage: FunctionComponent = () => {
const { services } = useKibana<CoreStart>();
const currentUser = useCurrentUser();
const userProfile = useUserProfile<{ avatar: UserAvatarData }>('avatar');
const userProfile = useUserProfile<{ avatar: UserProfileAvatarData }>('avatar');
// If we fail to load profile, we treat it as a failure _only_ if user is supposed
// to have a profile. For example, anonymous and users authenticated via

View file

@ -13,7 +13,7 @@ import React from 'react';
import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks';
import { UserProfileAPIClient } from '..';
import type { UserData } from '../../../common';
import type { UserProfileData } from '../../../common';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import { UserAvatar } from '../../components';
import { UserAPIClient } from '../../management';
@ -62,7 +62,7 @@ describe('useUserProfileForm', () => {
});
it('should initialise form with values from user profile', () => {
const data: UserData = {
const data: UserProfileData = {
avatar: {},
};
const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper });
@ -86,7 +86,7 @@ describe('useUserProfileForm', () => {
});
it('should initialise form with values from user avatar if present', () => {
const data: UserData = {
const data: UserProfileData = {
avatar: {
imageUrl: 'avatar.png',
},
@ -106,7 +106,7 @@ describe('useUserProfileForm', () => {
});
it('should update initials when full name changes', async () => {
const data: UserData = {};
const data: UserProfileData = {};
const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper });
await act(async () => {
@ -118,7 +118,7 @@ describe('useUserProfileForm', () => {
});
it('should save user and user profile when submitting form', async () => {
const data: UserData = {};
const data: UserProfileData = {};
const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper });
await act(async () => {
@ -138,7 +138,7 @@ describe('useUserProfileForm', () => {
},
};
const data: UserData = {};
const data: UserProfileData = {};
const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper });
await act(async () => {
@ -149,7 +149,7 @@ describe('useUserProfileForm', () => {
});
it('should add toast after submitting form successfully', async () => {
const data: UserData = {};
const data: UserProfileData = {};
const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper });
await act(async () => {
@ -160,7 +160,7 @@ describe('useUserProfileForm', () => {
});
it('should add toast after submitting form failed', async () => {
const data: UserData = {};
const data: UserProfileData = {};
const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper });
coreStart.http.post.mockRejectedValue(new Error('Error'));
@ -173,7 +173,7 @@ describe('useUserProfileForm', () => {
});
it('should set initial values to current values after submitting form successfully', async () => {
const data: UserData = {};
const data: UserProfileData = {};
const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper });
await act(async () => {
@ -186,7 +186,7 @@ describe('useUserProfileForm', () => {
describe('User Avatar Form', () => {
it('should display if the User is not a cloud user', () => {
const data: UserData = {};
const data: UserProfileData = {};
const nonCloudUser = mockAuthenticatedUser({ elastic_cloud_user: false });
@ -209,7 +209,7 @@ describe('useUserProfileForm', () => {
});
it('should not display if the User is a cloud user', () => {
const data: UserData = {};
const data: UserProfileData = {};
const cloudUser = mockAuthenticatedUser({ elastic_cloud_user: true });

View file

@ -34,7 +34,7 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { AuthenticatedUser, UserAvatarData } from '../../../common';
import type { AuthenticatedUser, UserProfileAvatarData } from '../../../common';
import {
canUserChangeDetails,
canUserChangePassword,
@ -58,7 +58,7 @@ import { createImageHandler, getRandomColor, IMAGE_FILE_TYPES, VALID_HEX_COLOR }
export interface UserProfileProps {
user: AuthenticatedUser;
data?: {
avatar?: UserAvatarData;
avatar?: UserProfileAvatarData;
};
}

View file

@ -10,17 +10,18 @@ import { Subject } from 'rxjs';
import type { HttpStart } from '@kbn/core/public';
import type { AuthenticatedUserProfile, UserData } from '../../../common';
import type { GetUserProfileResponse, UserProfileData } from '../../../common';
const USER_PROFILE_URL = '/internal/security/user_profile';
export class UserProfileAPIClient {
private readonly internalDataUpdates$: Subject<UserData> = new Subject();
private readonly internalDataUpdates$: Subject<UserProfileData> = new Subject();
/**
* Emits event whenever user profile is changed by the user.
*/
public readonly dataUpdates$: Observable<UserData> = this.internalDataUpdates$.asObservable();
public readonly dataUpdates$: Observable<UserProfileData> =
this.internalDataUpdates$.asObservable();
constructor(private readonly http: HttpStart) {}
@ -28,8 +29,8 @@ export class UserProfileAPIClient {
* Retrieves the user profile of the current user.
* @param dataPath By default `get()` returns user information, but does not return any user data. The optional "dataPath" parameter can be used to return personal data for this user.
*/
public get<T extends UserData>(dataPath?: string) {
return this.http.get<AuthenticatedUserProfile<T>>(USER_PROFILE_URL, {
public get<D extends UserProfileData>(dataPath?: string) {
return this.http.get<GetUserProfileResponse<D>>(USER_PROFILE_URL, {
query: { data: dataPath },
});
}
@ -38,7 +39,7 @@ export class UserProfileAPIClient {
* Updates user profile data of the current user.
* @param data Application data to be written (merged with existing data).
*/
public update<T extends UserData>(data: T) {
public update<D extends UserProfileData>(data: D) {
return this.http.post(`${USER_PROFILE_URL}/_data`, { body: JSON.stringify(data) }).then(() => {
this.internalDataUpdates$.next(data);
});

View file

@ -10,7 +10,7 @@ import useAsync from 'react-use/lib/useAsync';
import useObservable from 'react-use/lib/useObservable';
import { useSecurityApiClients } from '.';
import type { UserData } from '../../common';
import type { UserProfileData } from '../../common';
import type { AuthenticationServiceSetup } from '../authentication';
export interface AuthenticationProviderProps {
@ -28,7 +28,7 @@ export function useCurrentUser() {
return useAsync(authc.getCurrentUser, [authc]);
}
export function useUserProfile<T extends UserData>(dataPath?: string) {
export function useUserProfile<T extends UserProfileData>(dataPath?: string) {
const { userProfiles } = useSecurityApiClients();
const dataUpdateState = useObservable(userProfiles.dataUpdates$);
return useAsync(() => userProfiles.get<T>(dataPath), [userProfiles, dataUpdateState]);

View file

@ -10,7 +10,7 @@ import { EuiAvatar, useEuiTheme } from '@elastic/eui';
import type { FunctionComponent, HTMLAttributes } from 'react';
import React from 'react';
import type { UserAvatarData, UserInfo } from '../../common';
import type { UserProfileAvatarData, UserProfileUserInfoWithSecurity } from '../../common';
import {
getUserAvatarColor,
getUserAvatarInitials,
@ -19,8 +19,8 @@ import {
} from '../../common/model';
export interface UserAvatarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'color'> {
user?: Pick<UserInfo, 'username' | 'full_name'>;
avatar?: UserAvatarData;
user?: Pick<UserProfileUserInfoWithSecurity, 'username' | 'full_name'>;
avatar?: UserProfileAvatarData;
size?: EuiAvatarProps['size'];
isDisabled?: EuiAvatarProps['isDisabled'];
}

View file

@ -25,7 +25,14 @@ const useObservableMock = useObservable as jest.Mock;
const useUserProfileMock = jest.spyOn(UseCurrentUserImports, 'useUserProfile');
const useCurrentUserMock = jest.spyOn(UseCurrentUserImports, 'useCurrentUser');
const userProfile = userProfileMock.create();
const userProfileWithSecurity = userProfileMock.createWithSecurity();
const userProfile = {
...userProfileWithSecurity,
user: {
...userProfileWithSecurity.user,
authentication_provider: { type: 'basic', name: 'basic1' },
},
};
const userMenuLinks$ = new BehaviorSubject([]);
describe('SecurityNavControl', () => {

View file

@ -21,7 +21,7 @@ import type { Observable } from 'rxjs';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { UserAvatarData } from '../../common';
import type { UserProfileAvatarData } from '../../common';
import { getUserDisplayName, isUserAnonymous } from '../../common/model';
import { useCurrentUser, UserAvatar, useUserProfile } from '../components';
@ -47,7 +47,7 @@ export const SecurityNavControl: FunctionComponent<SecurityNavControlProps> = ({
const userMenuLinks = useObservable(userMenuLinks$, []);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const userProfile = useUserProfile<{ avatar: UserAvatarData }>('avatar');
const userProfile = useUserProfile<{ avatar: UserProfileAvatarData }>('avatar');
const currentUser = useCurrentUser(); // User profiles do not exist for anonymous users so need to fetch current user as well
const displayName = currentUser.value ? getUserDisplayName(currentUser.value) : '';

View file

@ -26,7 +26,7 @@ import { getDetailedErrorMessage, getErrorStatusCode } from '../errors';
import type { SecurityFeatureUsageServiceStart } from '../feature_usage';
import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags';
import type { Session } from '../session_management';
import type { UserProfileServiceStart } from '../user_profile';
import type { UserProfileServiceStartInternal } from '../user_profile';
import { APIKeys } from './api_keys';
import type { AuthenticationResult } from './authentication_result';
import type { ProviderLoginAttempt } from './authenticator';
@ -49,7 +49,7 @@ interface AuthenticationServiceStartParams {
clusterClient: IClusterClient;
audit: AuditServiceSetup;
featureUsageService: SecurityFeatureUsageServiceStart;
userProfileService: UserProfileServiceStart;
userProfileService: UserProfileServiceStartInternal;
session: PublicMethodsOf<Session>;
loggers: LoggerFactory;
applicationName: string;

View file

@ -1507,7 +1507,7 @@ describe('Authenticator', () => {
password: 'some-password',
};
mockOptions.userProfileService.activate.mockResolvedValue({
...userProfileMock.create(),
...userProfileMock.createWithSecurity(),
uid: 'new-profile-uid',
});

View file

@ -25,7 +25,7 @@ import type { ConfigType } from '../config';
import { getErrorStatusCode } from '../errors';
import type { SecurityFeatureUsageServiceStart } from '../feature_usage';
import type { Session, SessionValue } from '../session_management';
import type { UserProfileServiceStart } from '../user_profile';
import type { UserProfileServiceStartInternal } from '../user_profile';
import { AuthenticationResult } from './authentication_result';
import { canRedirectRequest } from './can_redirect_request';
import { DeauthenticationResult } from './deauthentication_result';
@ -80,7 +80,7 @@ export interface ProviderLoginAttempt {
export interface AuthenticatorOptions {
audit: AuditServiceSetup;
featureUsageService: SecurityFeatureUsageServiceStart;
userProfileService: UserProfileServiceStart;
userProfileService: UserProfileServiceStartInternal;
getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null;
config: Pick<ConfigType, 'authc'>;
basePath: IBasePath;

View file

@ -16,7 +16,7 @@ import { nextTick } from '@kbn/test-jest-helpers';
import {
mockAuthorizationModeFactory,
mockCheckPrivilegesDynamicallyWithRequestFactory,
mockCheckPrivilegesWithRequestFactory,
mockCheckPrivilegesFactory,
mockCheckSavedObjectsPrivilegesWithRequestFactory,
mockPrivilegesFactory,
mockRegisterPrivilegesWithCluster,
@ -25,7 +25,7 @@ import {
import { licenseMock } from '../../common/licensing/index.mock';
import type { OnlineStatusRetryScheduler } from '../elasticsearch';
import { AuthorizationService } from './authorization_service';
import { checkPrivilegesWithRequestFactory } from './check_privileges';
import { checkPrivilegesFactory } from './check_privileges';
import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically';
import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges';
import { authorizationModeFactory } from './mode';
@ -34,12 +34,16 @@ import { privilegesFactory } from './privileges';
const kibanaIndexName = '.a-kibana-index';
const application = `kibana-${kibanaIndexName}`;
const mockCheckPrivilegesWithRequest = Symbol();
const mockCheckUserProfilesPrivileges = Symbol();
const mockCheckPrivilegesDynamicallyWithRequest = Symbol();
const mockCheckSavedObjectsPrivilegesWithRequest = Symbol();
const mockPrivilegesService = Symbol();
const mockAuthorizationMode = Symbol();
beforeEach(() => {
mockCheckPrivilegesWithRequestFactory.mockReturnValue(mockCheckPrivilegesWithRequest);
mockCheckPrivilegesFactory.mockReturnValue({
checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest,
checkUserProfilesPrivileges: mockCheckUserProfilesPrivileges,
});
mockCheckPrivilegesDynamicallyWithRequestFactory.mockReturnValue(
mockCheckPrivilegesDynamicallyWithRequest
);
@ -83,7 +87,7 @@ it(`#setup returns exposed services`, () => {
expect(authz.applicationName).toBe(application);
expect(authz.checkPrivilegesWithRequest).toBe(mockCheckPrivilegesWithRequest);
expect(checkPrivilegesWithRequestFactory).toHaveBeenCalledWith(
expect(checkPrivilegesFactory).toHaveBeenCalledWith(
authz.actions,
getClusterClient,
authz.applicationName
@ -92,6 +96,7 @@ it(`#setup returns exposed services`, () => {
expect(authz.checkPrivilegesDynamicallyWithRequest).toBe(
mockCheckPrivilegesDynamicallyWithRequest
);
expect(authz.checkUserProfilesPrivileges).toBe(mockCheckUserProfilesPrivileges);
expect(checkPrivilegesDynamicallyWithRequestFactory).toHaveBeenCalledWith(
mockCheckPrivilegesWithRequest,
mockGetSpacesService

View file

@ -33,7 +33,7 @@ import type { SpacesService } from '../plugin';
import { Actions } from './actions';
import { initAPIAuthorization } from './api_authorization';
import { initAppAuthorization } from './app_authorization';
import { checkPrivilegesWithRequestFactory } from './check_privileges';
import { checkPrivilegesFactory } from './check_privileges';
import type { CheckPrivilegesDynamicallyWithRequest } from './check_privileges_dynamically';
import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically';
import type { CheckSavedObjectsPrivilegesWithRequest } from './check_saved_objects_privileges';
@ -45,7 +45,7 @@ import type { PrivilegesService } from './privileges';
import { privilegesFactory } from './privileges';
import { registerPrivilegesWithCluster } from './register_privileges_with_cluster';
import { ResetSessionPage } from './reset_session_page';
import type { CheckPrivilegesWithRequest } from './types';
import type { CheckPrivilegesWithRequest, CheckUserProfilesPrivileges } from './types';
import { validateFeaturePrivileges } from './validate_feature_privileges';
import { validateReservedPrivileges } from './validate_reserved_privileges';
@ -74,7 +74,7 @@ interface AuthorizationServiceStartParams {
export interface AuthorizationServiceSetupInternal extends AuthorizationServiceSetup {
actions: Actions;
checkPrivilegesWithRequest: CheckPrivilegesWithRequest;
checkUserProfilesPrivileges: (userProfileUids: Set<string>) => CheckUserProfilesPrivileges;
checkPrivilegesDynamicallyWithRequest: CheckPrivilegesDynamicallyWithRequest;
checkSavedObjectsPrivilegesWithRequest: CheckSavedObjectsPrivilegesWithRequest;
applicationName: string;
@ -125,7 +125,7 @@ export class AuthorizationService {
const actions = new Actions(packageVersion);
this.privileges = privilegesFactory(actions, features, license);
const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(
const { checkPrivilegesWithRequest, checkUserProfilesPrivileges } = checkPrivilegesFactory(
actions,
getClusterClient,
this.applicationName
@ -137,6 +137,7 @@ export class AuthorizationService {
mode,
privileges: this.privileges,
checkPrivilegesWithRequest,
checkUserProfilesPrivileges,
checkPrivilegesDynamicallyWithRequest: checkPrivilegesDynamicallyWithRequestFactory(
checkPrivilegesWithRequest,
getSpacesService

View file

@ -10,7 +10,7 @@ import { uniq } from 'lodash';
import { elasticsearchServiceMock, httpServerMock } from '@kbn/core/server/mocks';
import { GLOBAL_RESOURCE } from '../../common/constants';
import { checkPrivilegesWithRequestFactory } from './check_privileges';
import { checkPrivilegesFactory } from './check_privileges';
import type { HasPrivilegesResponse } from './types';
const application = 'kibana-our_application';
@ -32,7 +32,7 @@ const createMockClusterClient = (response: any) => {
return { mockClusterClient, mockScopedClusterClient };
};
describe('#atSpace', () => {
describe('#checkPrivilegesWithRequest.atSpace', () => {
const checkPrivilegesAtSpaceTest = async (options: {
spaceId: string;
kibanaPrivileges?: string | string[];
@ -45,7 +45,7 @@ describe('#atSpace', () => {
const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient(
options.esHasPrivilegesResponse
);
const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(
const { checkPrivilegesWithRequest } = checkPrivilegesFactory(
mockActions,
() => Promise.resolve(mockClusterClient),
application
@ -890,7 +890,7 @@ describe('#atSpace', () => {
},
},
});
const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(
const { checkPrivilegesWithRequest } = checkPrivilegesFactory(
mockActions,
() => Promise.resolve(mockClusterClient),
application
@ -914,7 +914,7 @@ describe('#atSpace', () => {
});
});
describe('#atSpaces', () => {
describe('#checkPrivilegesWithRequest.atSpaces', () => {
const checkPrivilegesAtSpacesTest = async (options: {
spaceIds: string[];
kibanaPrivileges?: string | string[];
@ -927,7 +927,7 @@ describe('#atSpaces', () => {
const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient(
options.esHasPrivilegesResponse
);
const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(
const { checkPrivilegesWithRequest } = checkPrivilegesFactory(
mockActions,
() => Promise.resolve(mockClusterClient),
application
@ -2131,7 +2131,7 @@ describe('#atSpaces', () => {
},
},
});
const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(
const { checkPrivilegesWithRequest } = checkPrivilegesFactory(
mockActions,
() => Promise.resolve(mockClusterClient),
application
@ -2155,7 +2155,7 @@ describe('#atSpaces', () => {
});
});
describe('#globally', () => {
describe('#checkPrivilegesWithRequest.globally', () => {
const checkPrivilegesGloballyTest = async (options: {
kibanaPrivileges?: string | string[];
elasticsearchPrivileges?: {
@ -2167,7 +2167,7 @@ describe('#globally', () => {
const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient(
options.esHasPrivilegesResponse
);
const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(
const { checkPrivilegesWithRequest } = checkPrivilegesFactory(
mockActions,
() => Promise.resolve(mockClusterClient),
application
@ -3021,7 +3021,7 @@ describe('#globally', () => {
},
},
});
const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(
const { checkPrivilegesWithRequest } = checkPrivilegesFactory(
mockActions,
() => Promise.resolve(mockClusterClient),
application
@ -3044,3 +3044,97 @@ describe('#globally', () => {
});
});
});
describe('#checkUserProfilesPrivileges.atSpace', () => {
const checkPrivilegesAtSpaceTest = async (options: {
spaceId: string;
uids: string[];
kibanaPrivileges: string[];
esHasPrivilegesResponse: Promise<{ has_privilege_uids: string[]; error_uids?: string[] }>;
}) => {
const mockClusterClient = elasticsearchServiceMock.createClusterClient();
mockClusterClient.asInternalUser.transport.request.mockImplementation(
() => options.esHasPrivilegesResponse
);
const { checkUserProfilesPrivileges } = checkPrivilegesFactory(
mockActions,
() => Promise.resolve(mockClusterClient),
application
);
const checkPrivileges = checkUserProfilesPrivileges(new Set(options.uids));
let actualResult;
let errorThrown = null;
try {
actualResult = await checkPrivileges.atSpace(options.spaceId, {
kibana: options.kibanaPrivileges,
});
} catch (err) {
errorThrown = err;
}
expect(mockClusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes(1);
expect(mockClusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '_security/profile/_has_privileges',
body: {
uids: options.uids,
privileges: {
application: [
{
application,
resources: [`space:${options.spaceId}`],
privileges: options.kibanaPrivileges
? uniq([mockActions.version, mockActions.login, ...options.kibanaPrivileges])
: [mockActions.version, mockActions.login],
},
],
},
},
});
if (errorThrown) {
throw errorThrown;
}
return actualResult;
};
it('successfully returns results of the privilege check', async () => {
await expect(
checkPrivilegesAtSpaceTest({
spaceId: 'space_1',
uids: ['uid-1', 'uid-2', 'uid-3'],
kibanaPrivileges: [
`saved_object:${savedObjectTypes[0]}/get`,
`saved_object:${savedObjectTypes[1]}/get`,
],
esHasPrivilegesResponse: Promise.resolve({
has_privilege_uids: ['uid-1', 'uid-2'],
error_uids: ['uid-3'],
}),
})
).resolves.toMatchInlineSnapshot(`
Object {
"errorUids": Array [
"uid-3",
],
"hasPrivilegeUids": Array [
"uid-1",
"uid-2",
],
}
`);
});
it(`throws if check privileges call fails`, async () => {
await expect(
checkPrivilegesAtSpaceTest({
spaceId: 'space_1',
uids: ['uid-1', 'uid-2', 'uid-3'],
kibanaPrivileges: [mockActions.login],
esHasPrivilegesResponse: Promise.reject(new Error('Oh no!')),
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
});
});

View file

@ -17,6 +17,9 @@ import type {
CheckPrivilegesOptions,
CheckPrivilegesPayload,
CheckPrivilegesResponse,
CheckUserProfilesPrivileges,
CheckUserProfilesPrivilegesPayload,
CheckUserProfilesPrivilegesResponse,
HasPrivilegesResponse,
HasPrivilegesResponseApplication,
} from './types';
@ -27,7 +30,7 @@ interface CheckPrivilegesActions {
version: string;
}
export function checkPrivilegesWithRequestFactory(
export function checkPrivilegesFactory(
actions: CheckPrivilegesActions,
getClusterClient: () => Promise<IClusterClient>,
applicationName: string
@ -40,23 +43,76 @@ export function checkPrivilegesWithRequestFactory(
);
};
return function checkPrivilegesWithRequest(request: KibanaRequest): CheckPrivileges {
const createApplicationPrivilegesCheck = (
resources: string[],
kibanaPrivileges: string | string[],
{ requireLoginAction }: CheckPrivilegesOptions
) => {
const normalizedKibanaPrivileges = Array.isArray(kibanaPrivileges)
? kibanaPrivileges
: [kibanaPrivileges];
return {
application: applicationName,
resources,
privileges: uniq([
actions.version,
...(requireLoginAction ? [actions.login] : []),
...normalizedKibanaPrivileges,
]),
};
};
function checkUserProfilesPrivileges(userProfileUids: Set<string>): CheckUserProfilesPrivileges {
const checkPrivilegesAtResources = async (
resources: string[],
privileges: CheckUserProfilesPrivilegesPayload
): Promise<CheckUserProfilesPrivilegesResponse> => {
const clusterClient = await getClusterClient();
const applicationPrivilegesCheck = createApplicationPrivilegesCheck(
resources,
privileges.kibana,
{ requireLoginAction: true }
);
const response = await clusterClient.asInternalUser.transport.request<{
has_privilege_uids: string[];
error_uids?: string[];
}>({
method: 'POST',
path: '_security/profile/_has_privileges',
body: {
uids: [...userProfileUids],
privileges: { application: [applicationPrivilegesCheck] },
},
});
return {
hasPrivilegeUids: response.has_privilege_uids,
errorUids: response.error_uids ?? [],
};
};
return {
async atSpace(spaceId: string, privileges: CheckUserProfilesPrivilegesPayload) {
const spaceResource = ResourceSerializer.serializeSpaceResource(spaceId);
return await checkPrivilegesAtResources([spaceResource], privileges);
},
};
}
function checkPrivilegesWithRequest(request: KibanaRequest): CheckPrivileges {
const checkPrivilegesAtResources = async (
resources: string[],
privileges: CheckPrivilegesPayload,
{ requireLoginAction = true }: CheckPrivilegesOptions = {}
): Promise<CheckPrivilegesResponse> => {
const kibanaPrivileges = Array.isArray(privileges.kibana)
? privileges.kibana
: privileges.kibana
? [privileges.kibana]
: [];
const allApplicationPrivileges = uniq([
actions.version,
...(requireLoginAction ? [actions.login] : []),
...kibanaPrivileges,
]);
const applicationPrivilegesCheck = createApplicationPrivilegesCheck(
resources,
privileges.kibana ?? [],
{ requireLoginAction }
);
const clusterClient = await getClusterClient();
const body = await clusterClient.asScoped(request).asCurrentUser.security.hasPrivileges({
@ -68,9 +124,7 @@ export function checkPrivilegesWithRequestFactory(
privileges: indexPrivileges as estypes.SecurityIndexPrivilege[],
})
),
application: [
{ application: applicationName, resources, privileges: allApplicationPrivileges },
],
application: [applicationPrivilegesCheck],
},
});
@ -79,7 +133,7 @@ export function checkPrivilegesWithRequestFactory(
validateEsPrivilegeResponse(
hasPrivilegesResponse,
applicationName,
allApplicationPrivileges,
applicationPrivilegesCheck.privileges,
resources
);
@ -165,5 +219,7 @@ export function checkPrivilegesWithRequestFactory(
return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privileges, options);
},
};
};
}
return { checkPrivilegesWithRequest, checkUserProfilesPrivileges };
}

View file

@ -15,6 +15,7 @@ export const authorizationMock = {
}: { version?: string; applicationName?: string } = {}) => ({
actions: actionsMock.create(version),
checkPrivilegesWithRequest: jest.fn(),
checkUserProfilesPrivileges: jest.fn(),
checkElasticsearchPrivilegesWithRequest: jest.fn(),
checkPrivilegesDynamicallyWithRequest: jest.fn(),
checkSavedObjectsPrivilegesWithRequest: jest.fn(),

View file

@ -5,9 +5,9 @@
* 2.0.
*/
export const mockCheckPrivilegesWithRequestFactory = jest.fn();
export const mockCheckPrivilegesFactory = jest.fn();
jest.mock('./check_privileges', () => ({
checkPrivilegesWithRequestFactory: mockCheckPrivilegesWithRequestFactory,
checkPrivilegesFactory: mockCheckPrivilegesFactory,
}));
export const mockCheckPrivilegesDynamicallyWithRequestFactory = jest.fn();

View file

@ -87,10 +87,61 @@ export interface CheckPrivileges {
): Promise<CheckPrivilegesResponse>;
}
/**
* Privileges that can be checked for the Kibana users.
*/
export interface CheckPrivilegesPayload {
/**
* A list of the Kibana specific privileges (usually generated with `security.authz.actions.*.get(...)`).
*/
kibana?: string | string[];
/**
* A set of the Elasticsearch cluster and index privileges.
*/
elasticsearch?: {
/**
* A list of Elasticsearch cluster privileges (`manage_security`, `create_snapshot` etc.).
*/
cluster: string[];
/**
* A map (index name <-> list of privileges) of Elasticsearch index privileges (`view_index_metadata`, `read` etc.).
*/
index: Record<string, string[]>;
};
}
/**
* An interface to check users profiles privileges in a specific context (only a single-space context is supported at
* the moment).
*/
export interface CheckUserProfilesPrivileges {
atSpace(
spaceId: string,
privileges: CheckUserProfilesPrivilegesPayload
): Promise<CheckUserProfilesPrivilegesResponse>;
}
/**
* Privileges that can be checked for the users profiles (only Kibana specific privileges are supported at the moment).
*/
export interface CheckUserProfilesPrivilegesPayload {
/**
* A list of the Kibana specific privileges (usually generated with `security.authz.actions.*.get(...)`).
*/
kibana: string[];
}
/**
* Response of the check privileges operation for the users profiles.
*/
export interface CheckUserProfilesPrivilegesResponse {
/**
* The subset of the requested profile IDs of the users that have all the requested privileges.
*/
hasPrivilegeUids: string[];
/**
* The subset of the requested profile IDs for which an error was encountered. It does not include the missing profile
* IDs or the profile IDs of the users that do not have all the requested privileges.
*/
errorUids: string[];
}

View file

@ -35,6 +35,12 @@ export type { SecurityPluginSetup, SecurityPluginStart };
export type { AuthenticatedUser } from '../common/model';
export { ROUTE_TAG_CAN_REDIRECT } from './routes/tags';
export type { AuditServiceSetup } from './audit';
export type {
UserProfileServiceStart,
UserProfileBulkGetParams,
UserProfileSuggestParams,
UserProfileRequiredPrivileges,
} from './user_profile';
export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = {
schema: ConfigSchema,

View file

@ -13,6 +13,7 @@ import { mockAuthenticatedUser } from '../common/model/authenticated_user.mock';
import { auditServiceMock } from './audit/mocks';
import { authenticationServiceMock } from './authentication/authentication_service.mock';
import { authorizationMock } from './authorization/index.mock';
import { userProfileServiceMock } from './user_profile/user_profile_service.mock';
function createSetupMock() {
const mockAuthz = authorizationMock.create();
@ -38,6 +39,7 @@ function createSetupMock() {
function createStartMock() {
const mockAuthz = authorizationMock.create();
const mockAuthc = authenticationServiceMock.createStart();
const mockUserProfiles = userProfileServiceMock.createStart();
return {
authc: {
apiKeys: mockAuthc.apiKeys,
@ -50,6 +52,10 @@ function createStartMock() {
checkSavedObjectsPrivilegesWithRequest: mockAuthz.checkSavedObjectsPrivilegesWithRequest,
mode: mockAuthz.mode,
},
userProfiles: {
suggest: mockUserProfiles.suggest,
bulkGet: mockUserProfiles.bulkGet,
},
};
}

View file

@ -187,6 +187,10 @@ describe('Security Plugin', () => {
"useRbacForRequest": [Function],
},
},
"userProfiles": Object {
"bulkGet": [Function],
"suggest": [Function],
},
}
`);
});

View file

@ -57,18 +57,13 @@ import { SessionManagementService } from './session_management';
import { setupSpacesClient } from './spaces';
import { registerSecurityUsageCollector } from './usage_collector';
import { UserProfileService } from './user_profile';
import type { UserProfileServiceStart } from './user_profile';
import type { UserProfileServiceStart, UserProfileServiceStartInternal } from './user_profile';
export type SpacesService = Pick<
SpacesPluginSetup['spacesService'],
'getSpaceId' | 'namespaceToSpaceId'
>;
export type FeaturesService = Pick<
FeaturesPluginSetup,
'getKibanaFeatures' | 'getElasticsearchFeatures'
>;
/**
* Describes public Security plugin contract returned at the `setup` stage.
*/
@ -113,6 +108,10 @@ export interface SecurityPluginStart {
* Authorization services to manage and access the permissions a particular user has.
*/
authz: AuthorizationServiceSetup;
/**
* User profiles services to retrieve user profiles.
*/
userProfiles: UserProfileServiceStart;
}
export interface PluginSetupDependencies {
@ -199,7 +198,7 @@ export class SecurityPlugin
};
private readonly userProfileService: UserProfileService;
private userProfileStart?: UserProfileServiceStart;
private userProfileStart?: UserProfileServiceStartInternal;
private readonly getUserProfileService = () => {
if (!this.userProfileStart) {
throw new Error(`userProfileStart is not registered!`);
@ -319,6 +318,8 @@ export class SecurityPlugin
getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request),
});
this.userProfileService.setup({ authz: this.authorizationSetup });
setupSpacesClient({
spaces,
audit: this.auditSetup,
@ -442,6 +443,10 @@ export class SecurityPlugin
this.authorizationSetup!.checkSavedObjectsPrivilegesWithRequest,
mode: this.authorizationSetup!.mode,
},
userProfiles: {
bulkGet: this.userProfileStart.bulkGet,
suggest: this.userProfileStart.suggest,
},
});
}

View file

@ -20,7 +20,7 @@ import type { ConfigType } from '../config';
import type { SecurityFeatureUsageServiceStart } from '../feature_usage';
import type { Session } from '../session_management';
import type { SecurityRouter } from '../types';
import type { UserProfileServiceStart } from '../user_profile';
import type { UserProfileServiceStartInternal } from '../user_profile';
import { defineAnalyticsRoutes } from './analytics';
import { defineAnonymousAccessRoutes } from './anonymous_access';
import { defineApiKeysRoutes } from './api_keys';
@ -51,7 +51,7 @@ export interface RouteDefinitionParams {
getFeatures: () => Promise<KibanaFeature[]>;
getFeatureUsageService: () => SecurityFeatureUsageServiceStart;
getAuthenticationService: () => InternalAuthenticationServiceStart;
getUserProfileService: () => UserProfileServiceStart;
getUserProfileService: () => UserProfileServiceStartInternal;
getAnonymousAccessService: () => AnonymousAccessServiceStart;
analyticsService: AnalyticsServiceSetup;
}

View file

@ -0,0 +1,123 @@
/*
* 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 type { ObjectType } from '@kbn/config-schema';
import type { RequestHandler, RouteConfig } from '@kbn/core/server';
import { kibanaResponseFactory } from '@kbn/core/server';
import { httpServerMock } from '@kbn/core/server/mocks';
import { userProfileMock } from '../../../common/model/user_profile.mock';
import type { SecurityRequestHandlerContext, SecurityRouter } from '../../types';
import type { UserProfileServiceStartInternal } from '../../user_profile';
import { userProfileServiceMock } from '../../user_profile/user_profile_service.mock';
import { routeDefinitionParamsMock } from '../index.mock';
import { defineBulkGetUserProfilesRoute } from './bulk_get';
function getMockContext() {
return {
licensing: {
license: { check: jest.fn().mockReturnValue({ check: 'valid' }) },
},
} as unknown as SecurityRequestHandlerContext;
}
describe('Bulk get profile routes', () => {
let router: jest.Mocked<SecurityRouter>;
let userProfileService: jest.Mocked<UserProfileServiceStartInternal>;
beforeEach(() => {
const routeParamsMock = routeDefinitionParamsMock.create();
router = routeParamsMock.router;
userProfileService = userProfileServiceMock.createStart();
routeParamsMock.getUserProfileService.mockReturnValue(userProfileService);
defineBulkGetUserProfilesRoute(routeParamsMock);
});
describe('get user profiles by their ids', () => {
let routeHandler: RequestHandler<any, any, any, SecurityRequestHandlerContext>;
let routeConfig: RouteConfig<any, any, any, any>;
beforeEach(() => {
const [updateRouteConfig, updateRouteHandler] = router.post.mock.calls.find(
([{ path }]) => path === '/internal/security/user_profile/_bulk_get'
)!;
routeConfig = updateRouteConfig;
routeHandler = updateRouteHandler;
});
it('correctly defines route.', () => {
expect(routeConfig.options).toEqual({ tags: ['access:bulkGetUserProfiles'] });
const bodySchema = (routeConfig.validate as any).body as ObjectType;
expect(() => bodySchema.validate(0)).toThrowErrorMatchingInlineSnapshot(
`"expected a plain object value, but found [number] instead."`
);
expect(() => bodySchema.validate(null)).toThrowErrorMatchingInlineSnapshot(
`"expected a plain object value, but found [null] instead."`
);
expect(() => bodySchema.validate(undefined)).toThrowErrorMatchingInlineSnapshot(
`"[uids]: expected value of type [array] but got [undefined]"`
);
expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot(
`"[uids]: expected value of type [array] but got [undefined]"`
);
expect(() => bodySchema.validate({ uids: [] })).toThrowErrorMatchingInlineSnapshot(
`"[uids]: array size is [0], but cannot be smaller than [1]"`
);
expect(bodySchema.validate({ uids: ['uid-1', 'uid-2'] })).toEqual({
uids: ['uid-1', 'uid-2'],
});
expect(bodySchema.validate({ uids: ['uid-1', 'uid-2'], dataPath: '*' })).toEqual({
uids: ['uid-1', 'uid-2'],
dataPath: '*',
});
});
it('fails if bulk get call fails.', async () => {
const unhandledException = new Error('Something went wrong.');
userProfileService.bulkGet.mockRejectedValue(unhandledException);
await expect(
routeHandler(
getMockContext(),
httpServerMock.createKibanaRequest({ body: { uids: ['uid-1', 'uid-2'], dataPath: '*' } }),
kibanaResponseFactory
)
).resolves.toEqual(expect.objectContaining({ status: 500, payload: unhandledException }));
expect(userProfileService.bulkGet).toBeCalledTimes(1);
expect(userProfileService.bulkGet).toBeCalledWith({
uids: new Set(['uid-1', 'uid-2']),
dataPath: '*',
});
});
it('returns user profiles.', async () => {
const userProfiles = [
userProfileMock.create({ uid: 'uid-1' }),
userProfileMock.create({ uid: 'uid-2' }),
];
userProfileService.bulkGet.mockResolvedValue(userProfiles);
await expect(
routeHandler(
getMockContext(),
httpServerMock.createKibanaRequest({ body: { uids: ['uid-1', 'uid-2'], dataPath: '*' } }),
kibanaResponseFactory
)
).resolves.toEqual(expect.objectContaining({ status: 200, payload: userProfiles }));
expect(userProfileService.bulkGet).toBeCalledTimes(1);
expect(userProfileService.bulkGet).toBeCalledWith({
uids: new Set(['uid-1', 'uid-2']),
dataPath: '*',
});
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { schema } from '@kbn/config-schema';
import type { RouteDefinitionParams } from '..';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
export function defineBulkGetUserProfilesRoute({
router,
getUserProfileService,
}: RouteDefinitionParams) {
router.post(
{
path: '/internal/security/user_profile/_bulk_get',
validate: {
body: schema.object({
uids: schema.arrayOf(schema.string(), { minSize: 1 }),
dataPath: schema.maybe(schema.string()),
}),
},
options: { tags: ['access:bulkGetUserProfiles'] },
},
createLicensedRouteHandler(async (context, request, response) => {
const userProfileServiceInternal = getUserProfileService();
try {
const profiles = await userProfileServiceInternal.bulkGet({
uids: new Set(request.body.uids),
dataPath: request.body.dataPath,
});
return response.ok({ body: profiles });
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -8,7 +8,7 @@
import { schema } from '@kbn/config-schema';
import type { RouteDefinitionParams } from '..';
import type { AuthenticatedUserProfile } from '../../../common';
import type { GetUserProfileResponse } from '../../../common';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { getPrintableSessionId } from '../../session_management';
import { createLicensedRouteHandler } from '../licensed_route_handler';
@ -42,7 +42,7 @@ export function defineGetUserProfileRoute({
const userProfileService = getUserProfileService();
try {
const profile = await userProfileService.get(session.userProfileId, request.query.data);
const body: AuthenticatedUserProfile = {
const body: GetUserProfileResponse = {
...profile,
user: { ...profile.user, authentication_provider: session.provider },
};

View file

@ -6,10 +6,12 @@
*/
import type { RouteDefinitionParams } from '..';
import { defineBulkGetUserProfilesRoute } from './bulk_get';
import { defineGetUserProfileRoute } from './get';
import { defineUpdateUserProfileDataRoute } from './update';
export function defineUserProfileRoutes(params: RouteDefinitionParams) {
defineUpdateUserProfileDataRoute(params);
defineGetUserProfileRoute(params);
defineBulkGetUserProfilesRoute(params);
}

View file

@ -18,7 +18,7 @@ import { authenticationServiceMock } from '../../authentication/authentication_s
import type { Session } from '../../session_management';
import { sessionMock } from '../../session_management/session.mock';
import type { SecurityRequestHandlerContext, SecurityRouter } from '../../types';
import type { UserProfileServiceStart } from '../../user_profile';
import type { UserProfileServiceStartInternal } from '../../user_profile';
import { userProfileServiceMock } from '../../user_profile/user_profile_service.mock';
import { routeDefinitionParamsMock } from '../index.mock';
import { defineUpdateUserProfileDataRoute } from './update';
@ -34,7 +34,7 @@ function getMockContext() {
describe('Update profile routes', () => {
let router: jest.Mocked<SecurityRouter>;
let session: jest.Mocked<PublicMethodsOf<Session>>;
let userProfileService: jest.Mocked<UserProfileServiceStart>;
let userProfileService: jest.Mocked<UserProfileServiceStartInternal>;
let authc: DeeplyMockedKeys<InternalAuthenticationServiceStart>;
beforeEach(() => {
const routeParamsMock = routeDefinitionParamsMock.create();

View file

@ -8,6 +8,10 @@
export { UserProfileService } from './user_profile_service';
export type {
UserProfileServiceStart,
UserProfileServiceStartInternal,
UserProfileServiceStartParams,
UserProfileSuggestParams,
UserProfileBulkGetParams,
UserProfileRequiredPrivileges,
} from './user_profile_service';
export type { UserProfileGrant } from './user_profile_grant';

View file

@ -5,13 +5,15 @@
* 2.0.
*/
import type { UserProfileServiceStart } from '.';
import type { UserProfileServiceStartInternal } from '.';
import { userProfileMock } from '../../common/model/user_profile.mock';
export const userProfileServiceMock = {
createStart: (): jest.Mocked<UserProfileServiceStart> => ({
activate: jest.fn().mockReturnValue(userProfileMock.create()),
createStart: (): jest.Mocked<UserProfileServiceStartInternal> => ({
activate: jest.fn().mockReturnValue(userProfileMock.createWithSecurity()),
get: jest.fn(),
update: jest.fn(),
suggest: jest.fn(),
bulkGet: jest.fn(),
}),
};

View file

@ -6,11 +6,17 @@
*/
import { errors } from '@elastic/elasticsearch';
import type {
SecurityActivateUserProfileResponse,
SecurityGetUserProfileResponse,
SecuritySuggestUserProfilesResponse,
} from '@elastic/elasticsearch/lib/api/types';
import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { nextTick } from '@kbn/test-jest-helpers';
import { userProfileMock } from '../../common/model/user_profile.mock';
import { authorizationMock } from '../authorization/index.mock';
import { securityMock } from '../mocks';
import { UserProfileService } from './user_profile_service';
@ -21,26 +27,14 @@ describe('UserProfileService', () => {
let mockStartParams: {
clusterClient: ReturnType<typeof elasticsearchServiceMock.createClusterClient>;
};
let mockAuthz: ReturnType<typeof authorizationMock.create>;
beforeEach(() => {
mockStartParams = {
clusterClient: elasticsearchServiceMock.createClusterClient(),
};
mockAuthz = authorizationMock.create();
const userProfile = userProfileMock.create({
uid: 'UID',
data: {
kibana: {
avatar: 'fun.gif',
},
other_app: {
secret: 'data',
},
},
});
mockStartParams.clusterClient.asInternalUser.transport.request.mockResolvedValue({
[userProfile.uid]: userProfile,
});
userProfileService.setup({ authz: mockAuthz });
});
afterEach(() => {
@ -52,13 +46,33 @@ describe('UserProfileService', () => {
expect(startContract).toMatchInlineSnapshot(`
Object {
"activate": [Function],
"bulkGet": [Function],
"get": [Function],
"suggest": [Function],
"update": [Function],
}
`);
});
describe('#get', () => {
beforeEach(() => {
const userProfile = userProfileMock.createWithSecurity({
uid: 'UID',
data: {
kibana: {
avatar: 'fun.gif',
},
other_app: {
secret: 'data',
},
},
});
mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({
[userProfile.uid]: userProfile,
} as unknown as SecurityGetUserProfileResponse);
});
it('should get user profile', async () => {
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.get('UID')).resolves.toMatchInlineSnapshot(`
@ -67,42 +81,28 @@ describe('UserProfileService', () => {
"avatar": "fun.gif",
},
"enabled": true,
"labels": Object {},
"uid": "UID",
"user": Object {
"active": true,
"authentication_provider": Object {
"name": "basic1",
"type": "basic",
},
"authentication_realm": Object {
"name": "native1",
"type": "native",
},
"authentication_type": "realm",
"elastic_cloud_user": false,
"email": "email",
"enabled": true,
"full_name": "full name",
"lookup_realm": Object {
"name": "native1",
"type": "native",
},
"metadata": Object {
"_reserved": false,
},
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"realm_domain": "some-realm-domain",
"realm_name": "some-realm",
"roles": Array [],
"username": "some-username",
},
}
`);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'GET',
path: '_security/profile/UID',
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).toHaveBeenCalledWith({
uid: 'UID',
});
});
it('should handle errors when get user profile fails', async () => {
mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockRejectedValue(
new Error('Fail')
);
const startContract = userProfileService.start(mockStartParams);
@ -118,37 +118,24 @@ describe('UserProfileService', () => {
"avatar": "fun.gif",
},
"enabled": true,
"labels": Object {},
"uid": "UID",
"user": Object {
"active": true,
"authentication_provider": Object {
"name": "basic1",
"type": "basic",
},
"authentication_realm": Object {
"name": "native1",
"type": "native",
},
"authentication_type": "realm",
"elastic_cloud_user": false,
"email": "email",
"enabled": true,
"full_name": "full name",
"lookup_realm": Object {
"name": "native1",
"type": "native",
},
"metadata": Object {
"_reserved": false,
},
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"realm_domain": "some-realm-domain",
"realm_name": "some-realm",
"roles": Array [],
"username": "some-username",
},
}
`);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'GET',
path: '_security/profile/UID?data=kibana.*',
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).toHaveBeenCalledWith({
uid: 'UID',
data: 'kibana.*',
});
});
});
@ -159,21 +146,20 @@ describe('UserProfileService', () => {
await startContract.update('UID', {
avatar: 'boring.png',
});
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
body: {
data: {
kibana: {
avatar: 'boring.png',
},
expect(
mockStartParams.clusterClient.asInternalUser.security.updateUserProfileData
).toHaveBeenCalledWith({
uid: 'UID',
data: {
kibana: {
avatar: 'boring.png',
},
},
method: 'POST',
path: '_security/profile/UID/_data',
});
});
it('should handle errors when update user profile fails', async () => {
mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue(
mockStartParams.clusterClient.asInternalUser.security.updateUserProfileData.mockRejectedValue(
new Error('Fail')
);
const startContract = userProfileService.start(mockStartParams);
@ -188,8 +174,8 @@ describe('UserProfileService', () => {
describe('#activate', () => {
beforeEach(() => {
mockStartParams.clusterClient.asInternalUser.transport.request.mockResolvedValue(
userProfileMock.create()
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile.mockResolvedValue(
userProfileMock.createWithSecurity() as unknown as SecurityActivateUserProfileResponse
);
});
@ -205,41 +191,28 @@ describe('UserProfileService', () => {
Object {
"data": Object {},
"enabled": true,
"labels": Object {},
"uid": "some-profile-uid",
"user": Object {
"active": true,
"authentication_provider": Object {
"name": "basic1",
"type": "basic",
},
"authentication_realm": Object {
"name": "native1",
"type": "native",
},
"authentication_type": "realm",
"elastic_cloud_user": false,
"email": "email",
"enabled": true,
"full_name": "full name",
"lookup_realm": Object {
"name": "native1",
"type": "native",
},
"metadata": Object {
"_reserved": false,
},
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"realm_domain": "some-realm-domain",
"realm_name": "some-realm",
"roles": Array [],
"username": "some-username",
},
}
`);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes(
1
);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '_security/profile/_activate',
body: { grant_type: 'password', password: 'password', username: 'some-username' },
expect(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
).toHaveBeenCalledWith({
grant_type: 'password',
password: 'password',
username: 'some-username',
});
});
@ -250,49 +223,32 @@ describe('UserProfileService', () => {
Object {
"data": Object {},
"enabled": true,
"labels": Object {},
"uid": "some-profile-uid",
"user": Object {
"active": true,
"authentication_provider": Object {
"name": "basic1",
"type": "basic",
},
"authentication_realm": Object {
"name": "native1",
"type": "native",
},
"authentication_type": "realm",
"elastic_cloud_user": false,
"email": "email",
"enabled": true,
"full_name": "full name",
"lookup_realm": Object {
"name": "native1",
"type": "native",
},
"metadata": Object {
"_reserved": false,
},
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"realm_domain": "some-realm-domain",
"realm_name": "some-realm",
"roles": Array [],
"username": "some-username",
},
}
`);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes(
1
);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '_security/profile/_activate',
body: { grant_type: 'access_token', access_token: 'some-token' },
});
expect(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
).toHaveBeenCalledWith({ grant_type: 'access_token', access_token: 'some-token' });
});
it('fails if activation fails with non-409 error', async () => {
const failureReason = new errors.ResponseError(
securityMock.createApiResponse({ statusCode: 500, body: 'some message' })
);
mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile.mockRejectedValue(
failureReason
);
@ -300,14 +256,12 @@ describe('UserProfileService', () => {
await expect(
startContract.activate({ type: 'accessToken', accessToken: 'some-token' })
).rejects.toBe(failureReason);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes(
1
);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '_security/profile/_activate',
body: { grant_type: 'access_token', access_token: 'some-token' },
});
expect(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
).toHaveBeenCalledWith({ grant_type: 'access_token', access_token: 'some-token' });
});
it('retries activation if initially fails with 409 error', async () => {
@ -316,9 +270,11 @@ describe('UserProfileService', () => {
const failureReason = new errors.ResponseError(
securityMock.createApiResponse({ statusCode: 409, body: 'some message' })
);
mockStartParams.clusterClient.asInternalUser.transport.request
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
.mockRejectedValueOnce(failureReason)
.mockResolvedValueOnce(userProfileMock.create());
.mockResolvedValueOnce(
userProfileMock.createWithSecurity() as unknown as SecurityActivateUserProfileResponse
);
const startContract = userProfileService.start(mockStartParams);
const activatePromise = startContract.activate({
@ -332,42 +288,25 @@ describe('UserProfileService', () => {
Object {
"data": Object {},
"enabled": true,
"labels": Object {},
"uid": "some-profile-uid",
"user": Object {
"active": true,
"authentication_provider": Object {
"name": "basic1",
"type": "basic",
},
"authentication_realm": Object {
"name": "native1",
"type": "native",
},
"authentication_type": "realm",
"elastic_cloud_user": false,
"email": "email",
"enabled": true,
"full_name": "full name",
"lookup_realm": Object {
"name": "native1",
"type": "native",
},
"metadata": Object {
"_reserved": false,
},
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"realm_domain": "some-realm-domain",
"realm_name": "some-realm",
"roles": Array [],
"username": "some-username",
},
}
`);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes(
2
);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '_security/profile/_activate',
body: { grant_type: 'access_token', access_token: 'some-token' },
});
expect(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
).toHaveBeenCalledTimes(2);
expect(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
).toHaveBeenCalledWith({ grant_type: 'access_token', access_token: 'some-token' });
});
it('fails if activation max retries exceeded', async () => {
@ -376,7 +315,7 @@ describe('UserProfileService', () => {
const failureReason = new errors.ResponseError(
securityMock.createApiResponse({ statusCode: 409, body: 'some message' })
);
mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile.mockRejectedValue(
failureReason
);
@ -399,13 +338,544 @@ describe('UserProfileService', () => {
jest.runAllTimers();
await expect(activatePromise).rejects.toBe(failureReason);
expect(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
).toHaveBeenCalledTimes(3);
expect(
mockStartParams.clusterClient.asInternalUser.security.activateUserProfile
).toHaveBeenCalledWith({ grant_type: 'access_token', access_token: 'some-token' });
});
});
describe('#bulkGet', () => {
it('properly parses returned profiles', async () => {
mockStartParams.clusterClient.asInternalUser.transport.request.mockResolvedValue({
profiles: [
userProfileMock.createWithSecurity({
uid: 'UID-1',
user: {
username: 'user-1',
display_name: 'display-name-1',
full_name: 'full-name-1',
realm_name: 'some-realm',
realm_domain: 'some-domain',
roles: ['role-1'],
},
}),
userProfileMock.createWithSecurity({
uid: 'UID-2',
user: {
username: 'user-2',
display_name: 'display-name-2',
full_name: 'full-name-2',
realm_name: 'some-realm',
realm_domain: 'some-domain',
roles: ['role-2'],
},
}),
],
});
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.bulkGet({ uids: new Set(['UID-1', 'UID-2']) })).resolves
.toMatchInlineSnapshot(`
Array [
Object {
"data": Object {},
"enabled": true,
"uid": "UID-1",
"user": Object {
"display_name": "display-name-1",
"email": undefined,
"full_name": "full-name-1",
"username": "user-1",
},
},
Object {
"data": Object {},
"enabled": true,
"uid": "UID-2",
"user": Object {
"display_name": "display-name-2",
"email": undefined,
"full_name": "full-name-2",
"username": "user-2",
},
},
]
`);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes(
3
1
);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '_security/profile/_activate',
body: { grant_type: 'access_token', access_token: 'some-token' },
path: '_security/profile/_suggest',
body: { hint: { uids: ['UID-1', 'UID-2'] }, size: 2 },
});
});
it('filters out not requested profiles', async () => {
mockStartParams.clusterClient.asInternalUser.transport.request.mockResolvedValue({
profiles: [
userProfileMock.createWithSecurity({ uid: 'UID-1' }),
userProfileMock.createWithSecurity({ uid: 'UID-2' }),
userProfileMock.createWithSecurity({ uid: 'UID-NOT-REQUESTED' }),
],
});
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.bulkGet({ uids: new Set(['UID-1', 'UID-2', 'UID-3']) })).resolves
.toMatchInlineSnapshot(`
Array [
Object {
"data": Object {},
"enabled": true,
"uid": "UID-1",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"username": "some-username",
},
},
Object {
"data": Object {},
"enabled": true,
"uid": "UID-2",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"username": "some-username",
},
},
]
`);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes(
1
);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '_security/profile/_suggest',
body: { hint: { uids: ['UID-1', 'UID-2', 'UID-3'] }, size: 3 },
});
});
it('should request data if data path is specified', async () => {
mockStartParams.clusterClient.asInternalUser.transport.request.mockResolvedValue({
profiles: [
userProfileMock.createWithSecurity({
uid: 'UID-1',
data: { some: 'data', kibana: { some: 'kibana-data' } },
}),
],
});
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.bulkGet({ uids: new Set(['UID-1']), dataPath: '*' })).resolves
.toMatchInlineSnapshot(`
Array [
Object {
"data": Object {
"some": "kibana-data",
},
"enabled": true,
"uid": "UID-1",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"username": "some-username",
},
},
]
`);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes(
1
);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '_security/profile/_suggest',
body: {
hint: { uids: ['UID-1'] },
data: 'kibana.*',
size: 1,
},
});
});
it('fails if Elasticsearch returns error', async () => {
const failureReason = new errors.ResponseError(
securityMock.createApiResponse({ statusCode: 500, body: 'some message' })
);
mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue(
failureReason
);
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.bulkGet({ uids: new Set(['UID-1', 'UID-2']) })).rejects.toBe(
failureReason
);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes(
1
);
expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '_security/profile/_suggest',
body: { hint: { uids: ['UID-1', 'UID-2'] }, size: 2 },
});
});
});
describe('#suggest', () => {
it('properly parses returned profiles without privileges check', async () => {
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles.mockResolvedValue({
profiles: [
userProfileMock.createWithSecurity({
uid: 'UID-1',
user: {
username: 'user-1',
display_name: 'display-name-1',
full_name: 'full-name-1',
realm_name: 'some-realm',
realm_domain: 'some-domain',
roles: ['role-1'],
},
}),
userProfileMock.createWithSecurity({
uid: 'UID-2',
user: {
username: 'user-2',
display_name: 'display-name-2',
full_name: 'full-name-2',
realm_name: 'some-realm',
realm_domain: 'some-domain',
roles: ['role-2'],
},
}),
],
} as unknown as SecuritySuggestUserProfilesResponse);
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.suggest({ size: 50, name: 'some' })).resolves
.toMatchInlineSnapshot(`
Array [
Object {
"data": Object {},
"enabled": true,
"uid": "UID-1",
"user": Object {
"display_name": "display-name-1",
"email": undefined,
"full_name": "full-name-1",
"username": "user-1",
},
},
Object {
"data": Object {},
"enabled": true,
"uid": "UID-2",
"user": Object {
"display_name": "display-name-2",
"email": undefined,
"full_name": "full-name-2",
"username": "user-2",
},
},
]
`);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).toHaveBeenCalledWith({
name: 'some',
size: 50,
});
expect(mockAuthz.checkUserProfilesPrivileges).not.toHaveBeenCalled();
});
it('should request data if data path is specified', async () => {
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles.mockResolvedValue({
profiles: [
userProfileMock.createWithSecurity({
uid: 'UID-1',
data: { some: 'data', kibana: { some: 'kibana-data' } },
}),
],
} as unknown as SecuritySuggestUserProfilesResponse);
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.suggest({ name: 'some', dataPath: '*' })).resolves
.toMatchInlineSnapshot(`
Array [
Object {
"data": Object {
"some": "kibana-data",
},
"enabled": true,
"uid": "UID-1",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"username": "some-username",
},
},
]
`);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).toHaveBeenCalledWith({
name: 'some',
size: 10,
data: 'kibana.*',
});
expect(mockAuthz.checkUserProfilesPrivileges).not.toHaveBeenCalled();
});
it('fails if requested too many suggestions', async () => {
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.suggest({ name: 'som', size: 101 })).rejects.toMatchInlineSnapshot(
`[Error: Can return up to 100 suggestions, but 101 suggestions were requested.]`
);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).not.toHaveBeenCalled();
expect(mockAuthz.checkUserProfilesPrivileges).not.toHaveBeenCalled();
});
it('fails if Elasticsearch suggest API returns error', async () => {
const failureReason = new errors.ResponseError(
securityMock.createApiResponse({ statusCode: 500, body: 'some message' })
);
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles.mockRejectedValue(
failureReason
);
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.suggest({ name: 'some' })).rejects.toBe(failureReason);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).toHaveBeenCalledWith({
name: 'some',
size: 10,
});
});
it('properly handles privileges checks when privileges can be checked in one go', async () => {
// In this test we'd like to simulate the following case:
// 1. User requests 3 results with privileges check
// 2. Kibana will fetch 10 (min batch) results
// 3. Only UID-0, UID-1 and UID-8 profiles will have necessary privileges
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles.mockResolvedValue({
profiles: Array.from({ length: 10 }).map((_, index) =>
userProfileMock.createWithSecurity({
uid: `UID-${index}`,
data: { some: 'data', kibana: { some: `kibana-data-${index}` } },
})
),
} as unknown as SecuritySuggestUserProfilesResponse);
const mockAtSpacePrivilegeCheck = { atSpace: jest.fn() };
mockAtSpacePrivilegeCheck.atSpace.mockResolvedValue({
hasPrivilegeUids: ['UID-0', 'UID-1', 'UID-8'],
errorUids: [],
});
mockAuthz.checkUserProfilesPrivileges.mockReturnValue(mockAtSpacePrivilegeCheck);
const startContract = userProfileService.start(mockStartParams);
await expect(
startContract.suggest({
name: 'some',
size: 3,
dataPath: '*',
requiredPrivileges: {
spaceId: 'some-space',
privileges: { kibana: ['privilege-1', 'privilege-2'] },
},
})
).resolves.toMatchInlineSnapshot(`
Array [
Object {
"data": Object {
"some": "kibana-data-0",
},
"enabled": true,
"uid": "UID-0",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"username": "some-username",
},
},
Object {
"data": Object {
"some": "kibana-data-1",
},
"enabled": true,
"uid": "UID-1",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"username": "some-username",
},
},
Object {
"data": Object {
"some": "kibana-data-8",
},
"enabled": true,
"uid": "UID-8",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"username": "some-username",
},
},
]
`);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).toHaveBeenCalledWith({
name: 'some',
size: 10,
data: 'kibana.*',
});
expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenCalledTimes(1);
expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenCalledWith(
new Set(Array.from({ length: 10 }).map((_, index) => `UID-${index}`))
);
expect(mockAtSpacePrivilegeCheck.atSpace).toHaveBeenCalledTimes(1);
expect(mockAtSpacePrivilegeCheck.atSpace).toHaveBeenCalledWith('some-space', {
kibana: ['privilege-1', 'privilege-2'],
});
});
it('properly handles privileges checks when privileges have to be checked in multiple steps', async () => {
// In this test we'd like to simulate the following case:
// 1. User requests 11 results with privileges check
// 2. Kibana will fetch 22 (two times more than requested) results
// 3. Only UID-0 and UID-21 profiles will have necessary privileges
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles.mockResolvedValue({
profiles: Array.from({ length: 22 }).map((_, index) =>
userProfileMock.createWithSecurity({
uid: `UID-${index}`,
data: { some: 'data', kibana: { some: `kibana-data-${index}` } },
})
),
} as unknown as SecuritySuggestUserProfilesResponse);
const mockAtSpacePrivilegeCheck = { atSpace: jest.fn() };
mockAtSpacePrivilegeCheck.atSpace
.mockResolvedValueOnce({
hasPrivilegeUids: ['UID-0'],
errorUids: [],
})
.mockResolvedValueOnce({
hasPrivilegeUids: ['UID-20'],
errorUids: [],
})
.mockResolvedValueOnce({
hasPrivilegeUids: [],
errorUids: [],
});
mockAuthz.checkUserProfilesPrivileges.mockReturnValue(mockAtSpacePrivilegeCheck);
const startContract = userProfileService.start(mockStartParams);
await expect(
startContract.suggest({
name: 'some',
size: 11,
dataPath: '*',
requiredPrivileges: {
spaceId: 'some-space',
privileges: { kibana: ['privilege-1', 'privilege-2'] },
},
})
).resolves.toMatchInlineSnapshot(`
Array [
Object {
"data": Object {
"some": "kibana-data-0",
},
"enabled": true,
"uid": "UID-0",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"username": "some-username",
},
},
Object {
"data": Object {
"some": "kibana-data-20",
},
"enabled": true,
"uid": "UID-20",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"username": "some-username",
},
},
]
`);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles
).toHaveBeenCalledWith({
name: 'some',
size: 22,
data: 'kibana.*',
});
expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenCalledTimes(3);
// UID-0 -- UID-10 (11 UIDs - number of requested profiles)
expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenNthCalledWith(
1,
new Set(Array.from({ length: 11 }).map((_, index) => `UID-${index}`))
);
// UID-11 -- UID-20 (10 UIDs - min batch size)
expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenNthCalledWith(
2,
new Set(
Array.from({ length: 21 })
.map((_, index) => `UID-${index}`)
.slice(-10)
)
);
// UID-21 - remaining profile id
expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenNthCalledWith(3, new Set(['UID-21']));
expect(mockAtSpacePrivilegeCheck.atSpace).toHaveBeenCalledTimes(3);
expect(mockAtSpacePrivilegeCheck.atSpace).toHaveBeenNthCalledWith(1, 'some-space', {
kibana: ['privilege-1', 'privilege-2'],
});
expect(mockAtSpacePrivilegeCheck.atSpace).toHaveBeenNthCalledWith(2, 'some-space', {
kibana: ['privilege-1', 'privilege-2'],
});
expect(mockAtSpacePrivilegeCheck.atSpace).toHaveBeenNthCalledWith(3, 'some-space', {
kibana: ['privilege-1', 'privilege-2'],
});
});
});

View file

@ -5,150 +5,448 @@
* 2.0.
*/
import type {
SecurityActivateUserProfileRequest,
SecuritySuggestUserProfilesResponse,
} from '@elastic/elasticsearch/lib/api/types';
import type { SecurityUserProfile } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { IClusterClient, Logger } from '@kbn/core/server';
import type { AuthenticationProvider, UserData, UserInfo, UserProfile } from '../../common';
import type { UserProfile, UserProfileData, UserProfileWithSecurity } from '../../common';
import type { AuthorizationServiceSetupInternal } from '../authorization';
import type { CheckUserProfilesPrivilegesResponse } from '../authorization/types';
import { getDetailedErrorMessage, getErrorStatusCode } from '../errors';
import type { UserProfileGrant } from './user_profile_grant';
const KIBANA_DATA_ROOT = 'kibana';
const ACTIVATION_MAX_RETRIES = 3;
const ACTIVATION_RETRY_SCALE_DURATION_MS = 150;
const MAX_SUGGESTIONS_COUNT = 100;
const DEFAULT_SUGGESTIONS_COUNT = 10;
const MIN_SUGGESTIONS_FOR_PRIVILEGES_CHECK = 10;
/**
* A set of methods to work with Kibana user profiles.
*/
export interface UserProfileServiceStart {
/**
* Retrieves multiple user profiles by their identifiers.
* @param params Bulk get operation parameters.
* @param params.uids List of user profile identifiers.
* @param params.dataPath By default Elasticsearch returns user information, but does not return any user data. The
* optional "dataPath" parameter can be used to return personal data for the requested user profiles.
*/
bulkGet<D extends UserProfileData>(
params: UserProfileBulkGetParams
): Promise<Array<UserProfile<D>>>;
/**
* Retrieves a single user profile by identifier.
* @param params Suggest operation parameters.
* @param params.name Query string used to match name-related fields in user profiles. The following fields are
* treated as name-related: username, full_name and email.
* @param params.dataPath By default API returns user information, but does not return any user data. The optional
* "dataPath" parameter can be used to return personal data for this user (within `kibana` namespace).
*/
suggest<D extends UserProfileData>(
params: UserProfileSuggestParams
): Promise<Array<UserProfile<D>>>;
}
export interface UserProfileServiceStartInternal extends UserProfileServiceStart {
/**
* Activates user profile using provided user profile grant.
* @param grant User profile grant (username/password or access token).
*/
activate(grant: UserProfileGrant): Promise<UserProfile>;
activate(grant: UserProfileGrant): Promise<UserProfileWithSecurity>;
/**
* Retrieves a single user profile by identifier.
* @param uid User ID
* @param dataPath By default `get()` returns user information, but does not return any user data. The optional "dataPath" parameter can be used to return personal data for this user.
* Retrieves a single user profile by its identifier.
* @param uid User profile identifier.
* @param dataPath By default Elasticsearch returns user information, but does not return any user data. The optional
* "dataPath" parameter can be used to return personal data for the requested user profile.
*/
get<T extends UserData>(uid: string, dataPath?: string): Promise<UserProfile<T>>;
get<D extends UserProfileData>(
uid: string,
dataPath?: string
): Promise<UserProfileWithSecurity<D>>;
/**
* Updates user preferences by identifier.
* @param uid User ID
* @param data Application data to be written (merged with existing data).
*/
update<T extends UserData>(uid: string, data: T): Promise<void>;
update<D extends UserProfileData>(uid: string, data: D): Promise<void>;
}
type GetProfileResponse<T extends UserData> = Record<
string,
{
uid: string;
user: UserInfo;
data: {
[KIBANA_DATA_ROOT]: T;
};
access: {};
enabled: boolean;
last_synchronized: number;
authentication_provider: AuthenticationProvider;
}
>;
export interface UserProfileServiceSetupParams {
authz: AuthorizationServiceSetupInternal;
}
export interface UserProfileServiceStartParams {
clusterClient: IClusterClient;
}
/**
* The set of privileges that users associated with the suggested user profile should have for a specified space id.
*/
export interface UserProfileRequiredPrivileges {
/**
* The id of the Kibana Space.
*/
spaceId: string;
/**
* The set of the Kibana specific application privileges.
*/
privileges: { kibana: string[] };
}
/**
* Parameters for the bulk get API.
*/
export interface UserProfileBulkGetParams {
/**
* List of user profile identifiers.
*/
uids: Set<string>;
/**
* By default, suggest API returns user information, but does not return any user data. The optional "dataPath"
* parameter can be used to return personal data for this user (within `kibana` namespace only).
*/
dataPath?: string;
}
/**
* Parameters for the suggest API.
*/
export interface UserProfileSuggestParams {
/**
* Query string used to match name-related fields in user profiles. The following fields are treated as
* name-related: username, full_name and email.
*/
name: string;
/**
* Desired number of suggestion to return. The default value is 10.
*/
size?: number;
/**
* By default, suggest API returns user information, but does not return any user data. The optional "dataPath"
* parameter can be used to return personal data for this user (within `kibana` namespace only).
*/
dataPath?: string;
/**
* The set of the privileges that users associated with the suggested user profile should have in the specified space.
* If not specified, privileges check isn't performed and all matched profiles are returned irrespective to the
* privileges of the associated users.
*/
requiredPrivileges?: UserProfileRequiredPrivileges;
}
function parseUserProfile<D extends UserProfileData>(
rawUserProfile: SecurityUserProfile
): UserProfile<D> {
return {
uid: rawUserProfile.uid,
// @ts-expect-error @elastic/elasticsearch SecurityActivateUserProfileResponse.enabled: boolean
enabled: rawUserProfile.enabled,
data: rawUserProfile.data?.[KIBANA_DATA_ROOT] ?? {},
user: {
username: rawUserProfile.user.username,
// @elastic/elasticsearch types support `null` values for the `email`, but we don't.
email: rawUserProfile.user.email ?? undefined,
// @elastic/elasticsearch types support `null` values for the `full_name`, but we don't.
full_name: rawUserProfile.user.full_name ?? undefined,
// @ts-expect-error @elastic/elasticsearch SecurityUserProfileUser.display_name?: string
display_name: rawUserProfile.user.display_name ?? undefined,
},
};
}
function parseUserProfileWithSecurity<D extends UserProfileData>(
rawUserProfile: SecurityUserProfile
): UserProfileWithSecurity<D> {
const userProfile = parseUserProfile<D>(rawUserProfile);
return {
...userProfile,
labels: rawUserProfile.labels?.[KIBANA_DATA_ROOT] ?? {},
user: {
...userProfile.user,
roles: rawUserProfile.user.roles,
// @ts-expect-error @elastic/elasticsearch SecurityUserProfileUser.realm_name: string
realm_name: rawUserProfile.user.realm_name,
// @ts-expect-error @elastic/elasticsearch SecurityUserProfileUser.realm_domain?: string
realm_domain: rawUserProfile.user.realm_domain,
},
};
}
export class UserProfileService {
private authz?: AuthorizationServiceSetupInternal;
constructor(private readonly logger: Logger) {}
start({ clusterClient }: UserProfileServiceStartParams): UserProfileServiceStart {
const { logger } = this;
setup({ authz }: UserProfileServiceSetupParams) {
this.authz = authz;
}
async function activate(grant: UserProfileGrant): Promise<UserProfile> {
logger.debug(`Activating user profile via ${grant.type} grant.`);
start({ clusterClient }: UserProfileServiceStartParams) {
return {
activate: this.activate.bind(this, clusterClient),
get: this.get.bind(this, clusterClient),
bulkGet: this.bulkGet.bind(this, clusterClient),
update: this.update.bind(this, clusterClient),
suggest: this.suggest.bind(this, clusterClient),
} as UserProfileServiceStartInternal;
}
const activateGrant =
grant.type === 'password'
? { grant_type: 'password', username: grant.username, password: grant.password }
: { grant_type: 'access_token', access_token: grant.accessToken };
/**
* See {@link UserProfileServiceStartInternal} for documentation.
*/
private async activate(clusterClient: IClusterClient, grant: UserProfileGrant) {
this.logger.debug(`Activating user profile via ${grant.type} grant.`);
// Profile activation is a multistep process that might or might not cause profile document to be created or
// updated. If Elasticsearch needs to handle multiple profile activation requests for the same user in parallel
// it can hit document version conflicts and fail (409 status code). In this case it's safe to retry activation
// request after some time. Most of the Kibana users won't be affected by this issue, but there are edge cases
// when users can be hit by the conflicts during profile activation, e.g. for PKI or Kerberos authentication when
// client certificate/ticket changes and multiple requests can trigger profile re-activation at the same time.
let activationRetriesLeft = ACTIVATION_MAX_RETRIES;
do {
try {
const response = await clusterClient.asInternalUser.transport.request<UserProfile>({
method: 'POST',
path: '_security/profile/_activate',
body: activateGrant,
});
const activateRequest: SecurityActivateUserProfileRequest =
grant.type === 'password'
? { grant_type: 'password', username: grant.username, password: grant.password }
: { grant_type: 'access_token', access_token: grant.accessToken };
logger.debug(`Successfully activated profile for "${response.user.username}".`);
// Profile activation is a multistep process that might or might not cause profile document to be created or
// updated. If Elasticsearch needs to handle multiple profile activation requests for the same user in parallel
// it can hit document version conflicts and fail (409 status code). In this case it's safe to retry activation
// request after some time. Most of the Kibana users won't be affected by this issue, but there are edge cases
// when users can be hit by the conflicts during profile activation, e.g. for PKI or Kerberos authentication when
// client certificate/ticket changes and multiple requests can trigger profile re-activation at the same time.
let activationRetriesLeft = ACTIVATION_MAX_RETRIES;
do {
try {
const response = await clusterClient.asInternalUser.security.activateUserProfile(
activateRequest
);
return response;
} catch (err) {
const detailedErrorMessage = getDetailedErrorMessage(err);
if (getErrorStatusCode(err) !== 409) {
logger.error(`Failed to activate user profile: ${detailedErrorMessage}.`);
throw err;
}
this.logger.debug(`Successfully activated profile for "${response.user.username}".`);
activationRetriesLeft--;
logger.error(
`Failed to activate user profile (retries left: ${activationRetriesLeft}): ${detailedErrorMessage}.`
);
if (activationRetriesLeft === 0) {
throw err;
}
return parseUserProfileWithSecurity<{}>(response);
} catch (err) {
const detailedErrorMessage = getDetailedErrorMessage(err);
if (getErrorStatusCode(err) !== 409) {
this.logger.error(`Failed to activate user profile: ${detailedErrorMessage}.`);
throw err;
}
await new Promise((resolve) =>
setTimeout(
resolve,
(ACTIVATION_MAX_RETRIES - activationRetriesLeft) * ACTIVATION_RETRY_SCALE_DURATION_MS
)
activationRetriesLeft--;
this.logger.error(
`Failed to activate user profile (retries left: ${activationRetriesLeft}): ${detailedErrorMessage}.`
);
} while (activationRetriesLeft > 0);
// This should be unreachable code, unless we have a bug in retry handling logic.
throw new Error('Failed to activate user profile, max retries exceeded.');
}
async function get<T extends UserData>(uid: string, dataPath?: string) {
try {
const body = await clusterClient.asInternalUser.transport.request<GetProfileResponse<T>>({
method: 'GET',
path: `_security/profile/${uid}${
dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : ''
}`,
});
return { ...body[uid], data: body[uid].data[KIBANA_DATA_ROOT] ?? {} };
} catch (error) {
logger.error(`Failed to retrieve user profile [uid=${uid}]: ${error.message}`);
throw error;
if (activationRetriesLeft === 0) {
throw err;
}
}
await new Promise((resolve) =>
setTimeout(
resolve,
(ACTIVATION_MAX_RETRIES - activationRetriesLeft) * ACTIVATION_RETRY_SCALE_DURATION_MS
)
);
} while (activationRetriesLeft > 0);
// This should be unreachable code, unless we have a bug in retry handling logic.
throw new Error('Failed to activate user profile, max retries exceeded.');
}
/**
* See {@link UserProfileServiceStartInternal} for documentation.
*/
private async get<D extends UserProfileData>(
clusterClient: IClusterClient,
uid: string,
dataPath?: string
) {
try {
const body = await clusterClient.asInternalUser.security.getUserProfile({
uid,
data: dataPath ? `${KIBANA_DATA_ROOT}.${dataPath}` : undefined,
});
return parseUserProfileWithSecurity<D>(body[uid]!);
} catch (error) {
this.logger.error(
`Failed to retrieve user profile [uid=${uid}]: ${getDetailedErrorMessage(error)}`
);
throw error;
}
}
/**
* See {@link UserProfileServiceStartInternal} for documentation.
*/
private async bulkGet<D extends UserProfileData>(
clusterClient: IClusterClient,
{ uids, dataPath }: UserProfileBulkGetParams
): Promise<Array<UserProfile<D>>> {
if (uids.size === 0) {
return [];
}
async function update<T extends UserData>(uid: string, data: T) {
try {
await clusterClient.asInternalUser.transport.request({
try {
// Use `transport.request` since `.security.suggestUserProfiles` implementation doesn't accept `hint` as a body
// parameter yet.
const body =
await clusterClient.asInternalUser.transport.request<SecuritySuggestUserProfilesResponse>({
method: 'POST',
path: `_security/profile/${uid}/_data`,
path: '_security/profile/_suggest',
body: {
data: {
[KIBANA_DATA_ROOT]: data,
},
hint: { uids: [...uids] },
// We need at most as many results as requested uids.
size: uids.size,
data: dataPath ? `${KIBANA_DATA_ROOT}.${dataPath}` : undefined,
},
});
return (
body.profiles
// "uids" is just a hint that allows to put user profiles with the requested uids on the top of the
// returned list, but if Elasticsearch cannot find user profiles for all requested uids it might include
// other "matched" user profiles as well.
.filter((rawProfile) => uids.has(rawProfile.uid))
.map((rawProfile) => parseUserProfile<D>(rawProfile))
);
} catch (error) {
this.logger.error(`Failed to bulk get user profiles: ${getDetailedErrorMessage(error)}`);
throw error;
}
}
/**
* See {@link UserProfileServiceStartInternal} for documentation.
*/
private async update<D extends UserProfileData>(
clusterClient: IClusterClient,
uid: string,
data: D
) {
try {
await clusterClient.asInternalUser.security.updateUserProfileData({
uid,
data: { [KIBANA_DATA_ROOT]: data },
});
} catch (error) {
this.logger.error(
`Failed to update user profile [uid=${uid}]: ${getDetailedErrorMessage(error)}`
);
throw error;
}
}
/**
* See {@link UserProfileServiceStartInternal} for documentation.
*/
private async suggest<D extends UserProfileData>(
clusterClient: IClusterClient,
params: UserProfileSuggestParams
): Promise<Array<UserProfile<D>>> {
const { name, size = DEFAULT_SUGGESTIONS_COUNT, dataPath, requiredPrivileges } = params;
if (size > MAX_SUGGESTIONS_COUNT) {
throw Error(
`Can return up to ${MAX_SUGGESTIONS_COUNT} suggestions, but ${size} suggestions were requested.`
);
}
// 1. If privileges are not defined, request as many results as has been requested
// 2. If privileges are defined, request two times more suggestions than requested to account
// for the results that don't pass privileges check, but not less than minimal batch size
// used to perform privileges check (fetching is cheap, privileges check is not).
const numberOfResultsToRequest =
(requiredPrivileges?.privileges.kibana.length ?? 0) > 0
? Math.max(size * 2, MIN_SUGGESTIONS_FOR_PRIVILEGES_CHECK)
: size;
try {
const body = await clusterClient.asInternalUser.security.suggestUserProfiles({
name,
size: numberOfResultsToRequest,
// If fetching data turns out to be a performance bottleneck, we can try to fetch data
// only for the profiles that pass privileges check as a separate bulkGet request.
data: dataPath ? `${KIBANA_DATA_ROOT}.${dataPath}` : undefined,
});
const filteredProfiles =
requiredPrivileges && requiredPrivileges?.privileges.kibana.length > 0
? await this.filterProfilesByPrivileges(body.profiles, requiredPrivileges, size)
: body.profiles;
return filteredProfiles.map((rawProfile) => parseUserProfile<D>(rawProfile));
} catch (error) {
this.logger.error(
`Failed to get user profiles suggestions [name=${name}]: ${getDetailedErrorMessage(error)}`
);
throw error;
}
}
private async filterProfilesByPrivileges(
profilesToFilter: SecurityUserProfile[],
requiredPrivileges: UserProfileRequiredPrivileges,
requiredSize: number
): Promise<SecurityUserProfile[]> {
// First try to check privileges for the maximum amount of profiles prioritizing a happy path i.e. first
// `requiredSize` profiles have all necessary privileges. Otherwise, check privileges for the remaining profiles in
// reasonably sized batches to optimize network round-trips until we find `requiredSize` profiles with necessary
// privileges, or we check all returned profiles.
const filteredProfiles = [];
while (profilesToFilter.length > 0 && filteredProfiles.length < requiredSize) {
const profilesBatch: Map<string, SecurityUserProfile> = new Map(
profilesToFilter
.splice(
0,
Math.max(requiredSize - filteredProfiles.length, MIN_SUGGESTIONS_FOR_PRIVILEGES_CHECK)
)
.map((profile) => [profile.uid, profile])
);
const profileUidsToFilter = new Set(profilesBatch.keys());
let response: CheckUserProfilesPrivilegesResponse;
try {
response = await this.authz!.checkUserProfilesPrivileges(profileUidsToFilter).atSpace(
requiredPrivileges.spaceId,
requiredPrivileges.privileges
);
} catch (error) {
logger.error(`Failed to update user profile [uid=${uid}]: ${error.message}`);
this.logger.error(
`Failed to check required privileges for the suggested profiles: ${getDetailedErrorMessage(
error
)}`
);
throw error;
}
const unknownUids = [];
for (const profileUid of response.hasPrivilegeUids) {
const filteredProfile = profilesBatch.get(profileUid);
if (filteredProfile) {
filteredProfiles.push(filteredProfile);
} else {
unknownUids.push(profileUid);
}
}
// Log unknown profile UIDs.
if (unknownUids.length > 0) {
this.logger.error(`Privileges check API returned unknown profile UIDs: ${unknownUids}.`);
}
// Log profile UIDs for which an error was encountered.
if (response.errorUids.length > 0) {
this.logger.error(
`Privileges check API failed for the following user profiles: ${response.errorUids}.`
);
}
}
return { activate, get, update };
return filteredProfiles;
}
}

View file

@ -0,0 +1,9 @@
{
"id": "userProfilesConsumerPlugin",
"owner": { "name": "Platform Security", "githubTeam": "kibana-security" },
"version": "8.0.0",
"kibanaVersion": "kibana",
"requiredPlugins":["security", "spaces", "features"],
"server": true,
"ui": false
}

View file

@ -0,0 +1,38 @@
/*
* 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 type { PluginInitializer, Plugin, CoreSetup } from '@kbn/core/server';
import {
PluginSetupContract as FeaturesPluginSetup,
PluginStartContract as FeaturesPluginStart,
} from '@kbn/features-plugin/server';
import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
import { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server';
import { initRoutes } from './init_routes';
export interface PluginSetupDependencies {
features: FeaturesPluginSetup;
security: SecurityPluginSetup;
spaces: SpacesPluginSetup;
}
export interface PluginStartDependencies {
features: FeaturesPluginStart;
security: SecurityPluginStart;
spaces: SpacesPluginStart;
}
export const plugin: PluginInitializer<void, void> = (): Plugin<
void,
void,
PluginSetupDependencies,
PluginStartDependencies
> => ({
setup: (core: CoreSetup<PluginStartDependencies>) => initRoutes(core),
start: () => {},
stop: () => {},
});

View file

@ -0,0 +1,44 @@
/*
* 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 { CoreSetup } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { PluginStartDependencies } from '.';
export function initRoutes(core: CoreSetup<PluginStartDependencies>) {
const router = core.http.createRouter();
router.post(
{
path: '/internal/user_profiles_consumer/_suggest',
validate: {
body: schema.object({
name: schema.string(),
dataPath: schema.maybe(schema.string()),
requiredAppPrivileges: schema.maybe(schema.arrayOf(schema.string())),
}),
},
},
async (context, request, response) => {
const [, pluginDeps] = await core.getStartServices();
const profiles = await pluginDeps.security.userProfiles.suggest({
name: request.body.name,
dataPath: request.body.dataPath,
requiredPrivileges: request.body.requiredAppPrivileges
? {
spaceId: pluginDeps.spaces.spacesService.getSpaceId(request),
privileges: {
kibana: request.body.requiredAppPrivileges.map((appPrivilege) =>
pluginDeps.security.authz.actions.app.get(appPrivilege)
),
},
}
: undefined,
});
return response.ok({ body: profiles });
}
);
}

View file

@ -0,0 +1,175 @@
/*
* 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 expect from '@kbn/expect';
import { parse as parseCookie, Cookie } from 'tough-cookie';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const security = getService('security');
describe('Getting user profiles in bulk', () => {
const usersSessions = new Map<string, { cookie: Cookie; uid: string }>();
before(async () => {
// 1. Create test users
await Promise.all(
['one', 'two', 'three'].map((userPrefix) =>
security.user.create(`user_${userPrefix}`, {
password: 'changeme',
roles: [`role_${userPrefix}`],
full_name: userPrefix.toUpperCase(),
email: `${userPrefix}@elastic.co`,
})
)
);
// 2. Activate user profiles
await Promise.all(
['one', 'two', 'three'].map(async (userPrefix) => {
const response = await supertestWithoutAuth
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic',
currentURL: '/',
params: { username: `user_${userPrefix}`, password: 'changeme' },
})
.expect(200);
const cookie = parseCookie(response.headers['set-cookie'][0])!;
const { body: profile } = await supertestWithoutAuth
.get('/internal/security/user_profile')
.set('Cookie', cookie.cookieString())
.expect(200);
usersSessions.set(`user_${userPrefix}`, { cookie, uid: profile.uid });
})
);
});
after(async () => {
await Promise.all(
['one', 'two', 'three'].map((userPrefix) => security.user.delete(`user_${userPrefix}`))
);
});
it('can get multiple profiles', async () => {
const profiles = await supertest
.post('/internal/security/user_profile/_bulk_get')
.set('kbn-xsrf', 'xxx')
.send({ uids: [usersSessions.get('user_one')!.uid, usersSessions.get('user_two')!.uid] })
.expect(200);
expect(profiles.body).to.have.length(2);
expectSnapshot(
profiles.body.map(({ user, data }: { user: unknown; data: unknown }) => ({ user, data }))
).toMatchInline(`
Array [
Object {
"data": Object {},
"user": Object {
"email": "two@elastic.co",
"full_name": "TWO",
"username": "user_two",
},
},
Object {
"data": Object {},
"user": Object {
"email": "one@elastic.co",
"full_name": "ONE",
"username": "user_one",
},
},
]
`);
});
it('can get multiple profiles with data', async () => {
// 1. Update user profile data.
await Promise.all(
['one', 'two'].map((userPrefix) =>
supertestWithoutAuth
.post('/internal/security/user_profile/_data')
.set('kbn-xsrf', 'xxx')
.set('Cookie', usersSessions.get(`user_${userPrefix}`)!.cookie.cookieString())
.send({ some: `data-${userPrefix}` })
.expect(200)
)
);
// 2. Data is not returned by default
let profiles = await supertest
.post('/internal/security/user_profile/_bulk_get')
.set('kbn-xsrf', 'xxx')
.send({ uids: [usersSessions.get('user_one')!.uid, usersSessions.get('user_two')!.uid] })
.expect(200);
expect(profiles.body).to.have.length(2);
expectSnapshot(
profiles.body.map(({ user, data }: { user: unknown; data: unknown }) => ({ user, data }))
).toMatchInline(`
Array [
Object {
"data": Object {},
"user": Object {
"email": "two@elastic.co",
"full_name": "TWO",
"username": "user_two",
},
},
Object {
"data": Object {},
"user": Object {
"email": "one@elastic.co",
"full_name": "ONE",
"username": "user_one",
},
},
]
`);
// 3. Only specific data is returned if requested.
profiles = await supertest
.post('/internal/security/user_profile/_bulk_get')
.set('kbn-xsrf', 'xxx')
.send({
uids: [usersSessions.get('user_one')!.uid, usersSessions.get('user_two')!.uid],
dataPath: 'some',
})
.expect(200);
expect(profiles.body).to.have.length(2);
expectSnapshot(
profiles.body.map(({ user, data }: { user: unknown; data: unknown }) => ({ user, data }))
).toMatchInline(`
Array [
Object {
"data": Object {
"some": "data-two",
},
"user": Object {
"email": "two@elastic.co",
"full_name": "TWO",
"username": "user_two",
},
},
Object {
"data": Object {
"some": "data-one",
},
"user": Object {
"email": "one@elastic.co",
"full_name": "ONE",
"username": "user_one",
},
},
]
`);
});
});
}

View file

@ -0,0 +1,15 @@
/*
* 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 { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('security APIs - User Profiles', function () {
loadTestFile(require.resolve('./suggest'));
loadTestFile(require.resolve('./bulk_get'));
});
}

View file

@ -0,0 +1,320 @@
/*
* 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 expect from '@kbn/expect';
import { parse as parseCookie, Cookie } from 'tough-cookie';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const security = getService('security');
describe('User profiles suggestions', () => {
const usersSessions = new Map<string, { cookie: Cookie }>();
before(async () => {
// 1. Create test roles.
await security.role.create('role_one', {
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [
{
spaces: ['default'],
base: [],
feature: { discover: ['read'], dashboard: ['read'], visualize: ['read'] },
},
{
spaces: ['space-a'],
base: [],
feature: { dashboard: ['read'], maps: ['read'] },
},
],
});
await security.role.create('role_two', {
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [
{ spaces: ['default'], base: [], feature: { apm: ['read'], dashboard: ['read'] } },
{
spaces: ['space-a'],
base: [],
feature: { discover: ['read'], dashboard: ['read'] },
},
],
});
await security.role.create('role_three', {
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [
{
spaces: ['default'],
base: [],
feature: { maps: ['read'], dashboard: ['read'], visualize: ['read'] },
},
{
spaces: ['space-a'],
base: [],
feature: { apm: ['read'], dashboard: ['read'] },
},
],
});
// 2. Create test users
await Promise.all(
['one', 'two', 'three'].map((userPrefix) =>
security.user.create(`user_${userPrefix}`, {
password: 'changeme',
roles: [`role_${userPrefix}`],
full_name: userPrefix.toUpperCase(),
email: `${userPrefix}@elastic.co`,
})
)
);
// 3. Activate user profiles
await Promise.all(
['one', 'two', 'three'].map(async (userPrefix) => {
const response = await supertestWithoutAuth
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic',
currentURL: '/',
params: { username: `user_${userPrefix}`, password: 'changeme' },
})
.expect(200);
usersSessions.set(`user_${userPrefix}`, {
cookie: parseCookie(response.headers['set-cookie'][0])!,
});
})
);
});
after(async () => {
await Promise.all(
['one', 'two', 'three'].flatMap((userPrefix) => [
security.role.delete(`role_${userPrefix}`),
security.user.delete(`user_${userPrefix}`),
])
);
});
it('can get suggestions in a default space', async () => {
// 1. No results since user one doesn't have access to `maps` app.
await supertest
.post('/internal/user_profiles_consumer/_suggest')
.set('kbn-xsrf', 'xxx')
.send({ name: 'one', requiredAppPrivileges: ['maps'] })
.expect(200, []);
// 2. One result with user `one` who has access to the `discover` app in a default space.
let suggestions = await supertest
.post('/internal/user_profiles_consumer/_suggest')
.set('kbn-xsrf', 'xxx')
.send({ name: 'one', requiredAppPrivileges: ['discover'] })
.expect(200);
expect(suggestions.body).to.have.length(1);
expectSnapshot(
suggestions.body.map(({ user, data }: { user: unknown; data: unknown }) => ({ user, data }))
).toMatchInline(`
Array [
Object {
"data": Object {},
"user": Object {
"email": "one@elastic.co",
"full_name": "ONE",
"username": "user_one",
},
},
]
`);
// 3. Two results with user `one` and `three` who have access to the `dashboards` and `visualize` apps in
// a default space.
suggestions = await supertest
.post('/internal/user_profiles_consumer/_suggest')
.set('kbn-xsrf', 'xxx')
.send({ name: 'elastic', requiredAppPrivileges: ['visualize', 'dashboards'] })
.expect(200);
expect(suggestions.body).to.have.length(2);
expectSnapshot(
suggestions.body.map(({ user, data }: { user: unknown; data: unknown }) => ({ user, data }))
).toMatchInline(`
Array [
Object {
"data": Object {},
"user": Object {
"email": "one@elastic.co",
"full_name": "ONE",
"username": "user_one",
},
},
Object {
"data": Object {},
"user": Object {
"email": "three@elastic.co",
"full_name": "THREE",
"username": "user_three",
},
},
]
`);
});
it('can get suggestions in a custom space', async () => {
// 1. No results since user one doesn't have access to `discover` app in a custom space.
await supertest
.post('/s/space-a/internal/user_profiles_consumer/_suggest')
.set('kbn-xsrf', 'xxx')
.send({ name: 'one', requiredAppPrivileges: ['discover'] })
.expect(200, []);
// 2. One result with user `one` who has access to the `maps` app in a custom space.
let suggestions = await supertest
.post('/s/space-a/internal/user_profiles_consumer/_suggest')
.set('kbn-xsrf', 'xxx')
.send({ name: 'one', requiredAppPrivileges: ['maps'] })
.expect(200);
expect(suggestions.body).to.have.length(1);
expectSnapshot(
suggestions.body.map(({ user, data }: { user: unknown; data: unknown }) => ({ user, data }))
).toMatchInline(`
Array [
Object {
"data": Object {},
"user": Object {
"email": "one@elastic.co",
"full_name": "ONE",
"username": "user_one",
},
},
]
`);
// 3. Three results with user `one`, `two` and `three` who have access to the `dashboards` app in a custom space.
suggestions = await supertest
.post('/s/space-a/internal/user_profiles_consumer/_suggest')
.set('kbn-xsrf', 'xxx')
.send({ name: 'elastic', requiredAppPrivileges: ['dashboards'] })
.expect(200);
expect(suggestions.body).to.have.length(3);
expectSnapshot(
suggestions.body.map(({ user, data }: { user: unknown; data: unknown }) => ({ user, data }))
).toMatchInline(`
Array [
Object {
"data": Object {},
"user": Object {
"email": "two@elastic.co",
"full_name": "TWO",
"username": "user_two",
},
},
Object {
"data": Object {},
"user": Object {
"email": "one@elastic.co",
"full_name": "ONE",
"username": "user_one",
},
},
Object {
"data": Object {},
"user": Object {
"email": "three@elastic.co",
"full_name": "THREE",
"username": "user_three",
},
},
]
`);
});
it('can get suggestions with data', async () => {
// 1. Update user profile data.
await supertestWithoutAuth
.post('/internal/security/user_profile/_data')
.set('kbn-xsrf', 'xxx')
.set('Cookie', usersSessions.get('user_one')!.cookie.cookieString())
.send({ some: 'data', some_nested: { data: 'nested_data' } })
.expect(200);
// 2. Data is not returned by default
let suggestions = await supertest
.post('/internal/user_profiles_consumer/_suggest')
.set('kbn-xsrf', 'xxx')
.send({ name: 'one', requiredAppPrivileges: ['discover'] })
.expect(200);
expect(suggestions.body).to.have.length(1);
expectSnapshot(
suggestions.body.map(({ user, data }: { user: unknown; data: unknown }) => ({ user, data }))
).toMatchInline(`
Array [
Object {
"data": Object {},
"user": Object {
"email": "one@elastic.co",
"full_name": "ONE",
"username": "user_one",
},
},
]
`);
// 3. Only specific data is returned if requested.
suggestions = await supertest
.post('/internal/user_profiles_consumer/_suggest')
.set('kbn-xsrf', 'xxx')
.send({ name: 'one', requiredAppPrivileges: ['discover'], dataPath: 'some' })
.expect(200);
expect(suggestions.body).to.have.length(1);
expectSnapshot(
suggestions.body.map(({ user, data }: { user: unknown; data: unknown }) => ({ user, data }))
).toMatchInline(`
Array [
Object {
"data": Object {
"some": "data",
},
"user": Object {
"email": "one@elastic.co",
"full_name": "ONE",
"username": "user_one",
},
},
]
`);
// 4. All data is returned if requested.
suggestions = await supertest
.post('/internal/user_profiles_consumer/_suggest')
.set('kbn-xsrf', 'xxx')
.send({ name: 'one', requiredAppPrivileges: ['discover'], dataPath: '*' })
.expect(200);
expect(suggestions.body).to.have.length(1);
expectSnapshot(
suggestions.body.map(({ user, data }: { user: unknown; data: unknown }) => ({ user, data }))
).toMatchInline(`
Array [
Object {
"data": Object {
"some": "data",
"some_nested": Object {
"data": "nested_data",
},
},
"user": Object {
"email": "one@elastic.co",
"full_name": "ONE",
"username": "user_one",
},
},
]
`);
});
});
}

View file

@ -0,0 +1,39 @@
/*
* 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 { resolve } from 'path';
import { FtrConfigProviderContext } from '@kbn/test';
import { services } from './services';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts'));
const userProfilesConsumerPlugin = resolve(
__dirname,
'./fixtures/user_profiles/user_profiles_consumer'
);
return {
testFiles: [require.resolve('./tests/user_profiles')],
servers: xPackAPITestsConfig.get('servers'),
security: { disableTestUser: true },
services,
junit: {
reportName: 'X-Pack Security API Integration Tests (User Profiles)',
},
esTestCluster: xPackAPITestsConfig.get('esTestCluster'),
kbnTestServer: {
...xPackAPITestsConfig.get('kbnTestServer'),
serverArgs: [
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
`--plugin-path=${userProfilesConsumerPlugin}`,
],
},
};
}