mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Introduce suggest user profile functionality. (#135063)
This commit is contained in:
parent
10cd177456
commit
ac15ee4540
47 changed files with 2329 additions and 415 deletions
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A set of fields describing Kibana user.
|
||||
*/
|
||||
export interface User {
|
||||
username: string;
|
||||
email?: string;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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'];
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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) : '';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1507,7 +1507,7 @@ describe('Authenticator', () => {
|
|||
password: 'some-password',
|
||||
};
|
||||
mockOptions.userProfileService.activate.mockResolvedValue({
|
||||
...userProfileMock.create(),
|
||||
...userProfileMock.createWithSecurity(),
|
||||
uid: 'new-profile-uid',
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!]`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -187,6 +187,10 @@ describe('Security Plugin', () => {
|
|||
"useRbacForRequest": [Function],
|
||||
},
|
||||
},
|
||||
"userProfiles": Object {
|
||||
"bulkGet": [Function],
|
||||
"suggest": [Function],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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: '*',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(),
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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: () => {},
|
||||
});
|
|
@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
}
|
39
x-pack/test/security_api_integration/user_profiles.config.ts
Normal file
39
x-pack/test/security_api_integration/user_profiles.config.ts
Normal 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}`,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue