Expose userProfiles.getCurrent and userProfiles.bulkGet APIs via the start contract on the client and server sides. (#136831)

This commit is contained in:
Aleh Zasypkin 2022-07-25 16:28:16 +02:00 committed by GitHub
parent 39404ba558
commit 5107282b01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 667 additions and 127 deletions

View file

@ -7,3 +7,4 @@
export { accountManagementApp } from './account_management_app';
export { UserProfileAPIClient } from './user_profile/user_profile_api_client';
export type { UserProfileBulkGetParams, UserProfileGetCurrentParams } from './user_profile';

View file

@ -8,3 +8,7 @@
export { UserProfile } from './user_profile';
export type { UserProfileProps, UserProfileFormValues } from './user_profile';
export type {
UserProfileGetCurrentParams,
UserProfileBulkGetParams,
} from './user_profile_api_client';

View file

@ -20,16 +20,16 @@ describe('UserProfileAPIClient', () => {
});
it('should get user profile without retrieving any user data', async () => {
await apiClient.get();
await apiClient.getCurrent();
expect(coreStart.http.get).toHaveBeenCalledWith('/internal/security/user_profile', {
query: { data: undefined },
query: { dataPath: undefined },
});
});
it('should get user profile and user data', async () => {
await apiClient.get('*');
await apiClient.getCurrent({ dataPath: '*' });
expect(coreStart.http.get).toHaveBeenCalledWith('/internal/security/user_profile', {
query: { data: '*' },
query: { dataPath: '*' },
});
});

View file

@ -10,9 +10,34 @@ import { Subject } from 'rxjs';
import type { HttpStart } from '@kbn/core/public';
import type { GetUserProfileResponse, UserProfileData } from '../../../common';
import type { GetUserProfileResponse, UserProfile, UserProfileData } from '../../../common';
const USER_PROFILE_URL = '/internal/security/user_profile';
/**
* Parameters for the get user profile for the current user API.
*/
export interface UserProfileGetCurrentParams {
/**
* By default, get 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 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;
}
export class UserProfileAPIClient {
private readonly internalDataUpdates$: Subject<UserProfileData> = new Subject();
@ -26,12 +51,28 @@ export class UserProfileAPIClient {
constructor(private readonly http: HttpStart) {}
/**
* 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.
* Retrieves the user profile of the current user. If the profile isn't available, e.g. for the anonymous users or
* users authenticated via authenticating proxies, the `null` value is returned.
* @param [params] Get current user profile operation parameters.
* @param params.dataPath By default `getCurrent()` 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<D extends UserProfileData>(dataPath?: string) {
return this.http.get<GetUserProfileResponse<D>>(USER_PROFILE_URL, {
query: { data: dataPath },
public getCurrent<D extends UserProfileData>(params?: UserProfileGetCurrentParams) {
return this.http.get<GetUserProfileResponse<D>>('/internal/security/user_profile', {
query: { dataPath: params?.dataPath },
});
}
/**
* 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.
*/
public bulkGet<D extends UserProfileData>(params: UserProfileBulkGetParams) {
return this.http.post<Array<UserProfile<D>>>('/internal/security/user_profile/_bulk_get', {
body: JSON.stringify(params),
});
}
@ -40,8 +81,10 @@ export class UserProfileAPIClient {
* @param data Application data to be written (merged with existing data).
*/
public update<D extends UserProfileData>(data: D) {
return this.http.post(`${USER_PROFILE_URL}/_data`, { body: JSON.stringify(data) }).then(() => {
this.internalDataUpdates$.next(data);
});
return this.http
.post('/internal/security/user_profile/_data', { body: JSON.stringify(data) })
.then(() => {
this.internalDataUpdates$.next(data);
});
}
}

View file

@ -31,5 +31,8 @@ export function useCurrentUser() {
export function useUserProfile<T extends UserProfileData>(dataPath?: string) {
const { userProfiles } = useSecurityApiClients();
const dataUpdateState = useObservable(userProfiles.dataUpdates$);
return useAsync(() => userProfiles.get<T>(dataPath), [userProfiles, dataUpdateState]);
return useAsync(
() => userProfiles.getCurrent<T>(dataPath ? { dataPath } : undefined),
[userProfiles, dataUpdateState]
);
}

View file

@ -20,6 +20,7 @@ export type { AuthenticatedUser } from '../common/model';
export type { SecurityLicense, SecurityLicenseFeatures } from '../common/licensing';
export type { UiApi, ChangePasswordProps, PersonalInfoProps } from './ui_api';
export type { UserMenuLink, SecurityNavControlServiceStart } from './nav_control';
export type { UserProfileBulkGetParams, UserProfileGetCurrentParams } from './account_management';
export type { AuthenticationServiceStart, AuthenticationServiceSetup } from './authentication';

View file

@ -22,6 +22,7 @@ function createStartMock() {
return {
authc: authenticationMock.createStart(),
navControlService: navControlServiceMock.createStart(),
userProfiles: { getCurrent: jest.fn(), bulkGet: jest.fn() },
uiApi: getUiApiMock.createStart(),
};
}

View file

@ -90,22 +90,28 @@ describe('Security Plugin', () => {
dataViews: {} as DataViewsPublicPluginStart,
features: {} as FeaturesPluginStart,
})
).toEqual({
uiApi: {
components: {
getChangePassword: expect.any(Function),
getPersonalInfo: expect.any(Function),
).toMatchInlineSnapshot(`
Object {
"authc": Object {
"areAPIKeysEnabled": [Function],
"getCurrentUser": [Function],
},
},
authc: {
getCurrentUser: expect.any(Function),
areAPIKeysEnabled: expect.any(Function),
},
navControlService: {
getUserMenuLinks$: expect.any(Function),
addUserMenuLinks: expect.any(Function),
},
});
"navControlService": Object {
"addUserMenuLinks": [Function],
"getUserMenuLinks$": [Function],
},
"uiApi": Object {
"components": Object {
"getChangePassword": [Function],
"getPersonalInfo": [Function],
},
},
"userProfiles": Object {
"bulkGet": [Function],
"getCurrent": [Function],
},
}
`);
});
it('starts Management Service if `management` plugin is available', () => {

View file

@ -21,13 +21,16 @@ import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/pu
import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import type { UserProfile, UserProfileData, UserProfileWithSecurity } from '../common';
import type { SecurityLicense } from '../common/licensing';
import { SecurityLicenseService } from '../common/licensing';
import type { UserProfileBulkGetParams, UserProfileGetCurrentParams } from './account_management';
import { accountManagementApp, UserProfileAPIClient } from './account_management';
import { AnalyticsService } from './analytics';
import { AnonymousAccessService } from './anonymous_access';
import type { AuthenticationServiceSetup, AuthenticationServiceStart } from './authentication';
import { AuthenticationService } from './authentication';
import type { SecurityApiClients } from './components';
import type { ConfigType } from './config';
import { ManagementService, UserAPIClient } from './management';
import type { SecurityNavControlServiceStart } from './nav_control';
@ -71,6 +74,7 @@ export class SecurityPlugin
private readonly anonymousAccessService = new AnonymousAccessService();
private readonly analyticsService = new AnalyticsService();
private authc!: AuthenticationServiceSetup;
private securityApiClients!: SecurityApiClients;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<ConfigType>();
@ -93,7 +97,7 @@ export class SecurityPlugin
http: core.http,
});
const securityApiClients = {
this.securityApiClients = {
userProfiles: new UserProfileAPIClient(core.http),
users: new UserAPIClient(core.http),
};
@ -101,7 +105,7 @@ export class SecurityPlugin
this.navControlService.setup({
securityLicense: license,
logoutUrl: getLogoutUrl(core.http),
securityApiClients,
securityApiClients: this.securityApiClients,
});
this.analyticsService.setup({ securityLicense: license });
@ -110,7 +114,7 @@ export class SecurityPlugin
authc: this.authc,
application: core.application,
getStartServices: core.getStartServices,
securityApiClients,
securityApiClients: this.securityApiClients,
});
if (management) {
@ -181,6 +185,14 @@ export class SecurityPlugin
uiApi: getUiApi({ core }),
navControlService: this.navControlService.start({ core, authc: this.authc }),
authc: this.authc as AuthenticationServiceStart,
userProfiles: {
getCurrent: this.securityApiClients.userProfiles.getCurrent.bind(
this.securityApiClients.userProfiles
),
bulkGet: this.securityApiClients.userProfiles.bulkGet.bind(
this.securityApiClients.userProfiles
),
},
};
}
@ -217,6 +229,32 @@ export interface SecurityPluginStart {
* Exposes authentication information about the currently logged in user.
*/
authc: AuthenticationServiceStart;
/**
* A set of methods to work with Kibana user profiles.
*/
userProfiles: {
/**
* Retrieves the user profile of the current user. If the profile isn't available, e.g. for the anonymous users or
* users authenticated via authenticating proxies, the `null` value is returned.
* @param [params] Get current user profile operation parameters.
* @param params.dataPath By default `getCurrent()` returns user information, but does not return any user data. The
* optional "dataPath" parameter can be used to return personal data for this user.
*/
getCurrent<D extends UserProfileData>(
params?: UserProfileGetCurrentParams
): Promise<UserProfileWithSecurity<D> | null>;
/**
* 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>>>;
};
/**
* Exposes UI components that will be loaded asynchronously.
* @deprecated

View file

@ -40,6 +40,7 @@ export type {
UserProfileBulkGetParams,
UserProfileSuggestParams,
UserProfileRequiredPrivileges,
UserProfileGetCurrentParams,
} from './user_profile';
export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = {

View file

@ -53,6 +53,7 @@ function createStartMock() {
mode: mockAuthz.mode,
},
userProfiles: {
getCurrent: mockUserProfiles.getCurrent,
suggest: mockUserProfiles.suggest,
bulkGet: mockUserProfiles.bulkGet,
},

View file

@ -189,6 +189,7 @@ describe('Security Plugin', () => {
},
"userProfiles": Object {
"bulkGet": [Function],
"getCurrent": [Function],
"suggest": [Function],
},
}

View file

@ -399,7 +399,7 @@ export class SecurityPlugin
});
this.session = session;
this.userProfileStart = this.userProfileService.start({ clusterClient });
this.userProfileStart = this.userProfileService.start({ clusterClient, session });
const config = this.getConfig();
this.authenticationStart = this.authenticationService.start({
@ -444,6 +444,7 @@ export class SecurityPlugin
mode: this.authorizationSetup!.mode,
},
userProfiles: {
getCurrent: this.userProfileStart.getCurrent,
bulkGet: this.userProfileStart.bulkGet,
suggest: this.userProfileStart.suggest,
},

View file

@ -0,0 +1,138 @@
/*
* 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 { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import { userProfileMock } from '../../../common/model/user_profile.mock';
import { authenticationServiceMock } from '../../authentication/authentication_service.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 { defineGetCurrentUserProfileRoute } from './get_current';
function getMockContext() {
return {
licensing: {
license: { check: jest.fn().mockReturnValue({ check: 'valid' }) },
},
} as unknown as SecurityRequestHandlerContext;
}
describe('Get current user profile routes', () => {
let router: jest.Mocked<SecurityRouter>;
let userProfileService: jest.Mocked<UserProfileServiceStartInternal>;
let authenticationService: ReturnType<typeof authenticationServiceMock.createStart>;
beforeEach(() => {
const routeParamsMock = routeDefinitionParamsMock.create();
router = routeParamsMock.router;
userProfileService = userProfileServiceMock.createStart();
routeParamsMock.getUserProfileService.mockReturnValue(userProfileService);
authenticationService = authenticationServiceMock.createStart();
routeParamsMock.getAuthenticationService.mockReturnValue(authenticationService);
defineGetCurrentUserProfileRoute(routeParamsMock);
});
describe('get profile for the currently authenticated user', () => {
let routeHandler: RequestHandler<any, any, any, SecurityRequestHandlerContext>;
let routeConfig: RouteConfig<any, any, any, any>;
beforeEach(() => {
const [updateRouteConfig, updateRouteHandler] = router.get.mock.calls.find(
([{ path }]) => path === '/internal/security/user_profile'
)!;
routeConfig = updateRouteConfig;
routeHandler = updateRouteHandler;
});
it('correctly defines route.', () => {
const querySchema = (routeConfig.validate as any).query as ObjectType;
expect(() => querySchema.validate(0)).toThrowErrorMatchingInlineSnapshot(
`"expected a plain object value, but found [number] instead."`
);
expect(() => querySchema.validate(null)).toThrowErrorMatchingInlineSnapshot(
`"expected a plain object value, but found [null] instead."`
);
expect(querySchema.validate(undefined)).toEqual({});
expect(querySchema.validate({})).toEqual({});
expect(querySchema.validate({ dataPath: '*' })).toEqual({ dataPath: '*' });
});
it('returns `404` if user is not available', async () => {
authenticationService.getCurrentUser.mockReturnValue(null);
await expect(
routeHandler(getMockContext(), httpServerMock.createKibanaRequest(), kibanaResponseFactory)
).resolves.toEqual(expect.objectContaining({ status: 404 }));
expect(userProfileService.getCurrent).not.toHaveBeenCalled();
});
it('returns `404` if profile is not available', async () => {
const mockRequest = httpServerMock.createKibanaRequest();
authenticationService.getCurrentUser.mockReturnValue(mockAuthenticatedUser());
userProfileService.getCurrent.mockResolvedValue(null);
await expect(
routeHandler(getMockContext(), mockRequest, kibanaResponseFactory)
).resolves.toEqual(expect.objectContaining({ status: 404 }));
expect(userProfileService.getCurrent).toBeCalledTimes(1);
expect(userProfileService.getCurrent).toBeCalledWith({ request: mockRequest });
});
it('fails if `getCurrent` call fails.', async () => {
const unhandledException = new Error('Something went wrong.');
const mockRequest = httpServerMock.createKibanaRequest();
authenticationService.getCurrentUser.mockReturnValue(mockAuthenticatedUser());
userProfileService.getCurrent.mockRejectedValue(unhandledException);
await expect(
routeHandler(getMockContext(), mockRequest, kibanaResponseFactory)
).resolves.toEqual(expect.objectContaining({ status: 500, payload: unhandledException }));
expect(userProfileService.getCurrent).toBeCalledTimes(1);
expect(userProfileService.getCurrent).toBeCalledWith({ request: mockRequest });
});
it('returns user profile for the current user.', async () => {
const mockRequest = httpServerMock.createKibanaRequest({ query: { dataPath: '*' } });
const mockUser = mockAuthenticatedUser();
authenticationService.getCurrentUser.mockReturnValue(mockUser);
const mockProfile = userProfileMock.createWithSecurity({ uid: 'uid-1' });
userProfileService.getCurrent.mockResolvedValue(mockProfile);
await expect(
routeHandler(getMockContext(), mockRequest, kibanaResponseFactory)
).resolves.toEqual(
expect.objectContaining({
status: 200,
payload: {
...mockProfile,
user: {
...mockProfile.user,
authentication_provider: mockUser.authentication_provider,
},
},
})
);
expect(userProfileService.getCurrent).toBeCalledTimes(1);
expect(userProfileService.getCurrent).toBeCalledWith({ request: mockRequest, dataPath: '*' });
});
});
});

View file

@ -8,48 +8,48 @@
import { schema } from '@kbn/config-schema';
import type { RouteDefinitionParams } from '..';
import type { GetUserProfileResponse } from '../../../common';
import type { GetUserProfileResponse, UserProfileWithSecurity } from '../../../common';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { getPrintableSessionId } from '../../session_management';
import { createLicensedRouteHandler } from '../licensed_route_handler';
export function defineGetUserProfileRoute({
export function defineGetCurrentUserProfileRoute({
router,
getSession,
getUserProfileService,
logger,
getAuthenticationService,
}: RouteDefinitionParams) {
router.get(
{
path: '/internal/security/user_profile',
validate: {
query: schema.object({ data: schema.maybe(schema.string()) }),
query: schema.object({ dataPath: schema.maybe(schema.string()) }),
},
},
createLicensedRouteHandler(async (context, request, response) => {
const session = await getSession().get(request);
if (!session) {
const authenticationService = await getAuthenticationService();
const currentUser = authenticationService.getCurrentUser(request);
if (!currentUser) {
return response.notFound();
}
if (!session.userProfileId) {
logger.warn(
`User profile missing from current session. (sid: ${getPrintableSessionId(session.sid)})`
);
return response.notFound();
}
const userProfileService = getUserProfileService();
let profile: UserProfileWithSecurity | null;
try {
const profile = await userProfileService.get(session.userProfileId, request.query.data);
const body: GetUserProfileResponse = {
...profile,
user: { ...profile.user, authentication_provider: session.provider },
};
return response.ok({ body });
profile = await getUserProfileService().getCurrent({
request,
dataPath: request.query.dataPath,
});
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
if (!profile) {
return response.notFound();
}
const body: GetUserProfileResponse = {
...profile,
user: { ...profile.user, authentication_provider: currentUser.authentication_provider },
};
return response.ok({ body });
})
);
}

View file

@ -7,11 +7,11 @@
import type { RouteDefinitionParams } from '..';
import { defineBulkGetUserProfilesRoute } from './bulk_get';
import { defineGetUserProfileRoute } from './get';
import { defineGetCurrentUserProfileRoute } from './get_current';
import { defineUpdateUserProfileDataRoute } from './update';
export function defineUserProfileRoutes(params: RouteDefinitionParams) {
defineUpdateUserProfileDataRoute(params);
defineGetUserProfileRoute(params);
defineGetCurrentUserProfileRoute(params);
defineBulkGetUserProfilesRoute(params);
}

View file

@ -13,5 +13,6 @@ export type {
UserProfileSuggestParams,
UserProfileBulkGetParams,
UserProfileRequiredPrivileges,
UserProfileGetCurrentParams,
} from './user_profile_service';
export type { UserProfileGrant } from './user_profile_grant';

View file

@ -11,7 +11,7 @@ import { userProfileMock } from '../../common/model/user_profile.mock';
export const userProfileServiceMock = {
createStart: (): jest.Mocked<UserProfileServiceStartInternal> => ({
activate: jest.fn().mockReturnValue(userProfileMock.createWithSecurity()),
get: jest.fn(),
getCurrent: jest.fn(),
update: jest.fn(),
suggest: jest.fn(),
bulkGet: jest.fn(),

View file

@ -12,12 +12,18 @@ import type {
SecuritySuggestUserProfilesResponse,
} from '@elastic/elasticsearch/lib/api/types';
import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
import {
elasticsearchServiceMock,
httpServerMock,
loggingSystemMock,
} from '@kbn/core/server/mocks';
import { nextTick } from '@kbn/test-jest-helpers';
import type { UserProfileWithSecurity } from '../../common';
import { userProfileMock } from '../../common/model/user_profile.mock';
import { authorizationMock } from '../authorization/index.mock';
import { securityMock } from '../mocks';
import { sessionMock } from '../session_management/session.mock';
import { UserProfileService } from './user_profile_service';
const logger = loggingSystemMock.createLogger();
@ -26,11 +32,13 @@ const userProfileService = new UserProfileService(logger);
describe('UserProfileService', () => {
let mockStartParams: {
clusterClient: ReturnType<typeof elasticsearchServiceMock.createClusterClient>;
session: ReturnType<typeof sessionMock.create>;
};
let mockAuthz: ReturnType<typeof authorizationMock.create>;
beforeEach(() => {
mockStartParams = {
clusterClient: elasticsearchServiceMock.createClusterClient(),
session: sessionMock.create(),
};
mockAuthz = authorizationMock.create();
@ -47,53 +55,104 @@ describe('UserProfileService', () => {
Object {
"activate": [Function],
"bulkGet": [Function],
"get": [Function],
"getCurrent": [Function],
"suggest": [Function],
"update": [Function],
}
`);
});
describe('#get', () => {
describe('#getCurrent', () => {
let mockUserProfile: UserProfileWithSecurity;
let mockRequest: ReturnType<typeof httpServerMock.createKibanaRequest>;
beforeEach(() => {
const userProfile = userProfileMock.createWithSecurity({
mockRequest = httpServerMock.createKibanaRequest();
mockUserProfile = userProfileMock.createWithSecurity({
uid: 'UID',
data: {
kibana: {
avatar: 'fun.gif',
},
other_app: {
secret: 'data',
},
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'],
},
});
mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({
[userProfile.uid]: userProfile,
[mockUserProfile.uid]: mockUserProfile,
} as unknown as SecurityGetUserProfileResponse);
});
it('should get user profile', async () => {
it('returns `null` if session is not available', async () => {
mockStartParams.session.get.mockResolvedValue(null);
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.get('UID')).resolves.toMatchInlineSnapshot(`
Object {
"data": Object {
"avatar": "fun.gif",
},
"enabled": true,
"labels": Object {},
"uid": "UID",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"realm_domain": "some-realm-domain",
"realm_name": "some-realm",
"roles": Array [],
"username": "some-username",
},
}
`);
await expect(startContract.getCurrent({ request: mockRequest })).resolves.toBeNull();
expect(mockStartParams.session.get).toHaveBeenCalledTimes(1);
expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest);
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).not.toHaveBeenCalled();
});
it('returns `null` if session available, but not user profile id', async () => {
mockStartParams.session.get.mockResolvedValue(
sessionMock.createValue({ userProfileId: undefined })
);
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.getCurrent({ request: mockRequest })).resolves.toBeNull();
expect(mockStartParams.session.get).toHaveBeenCalledTimes(1);
expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest);
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).not.toHaveBeenCalled();
});
it('fails if session retrieval fails', async () => {
const failureReason = new errors.ResponseError(
securityMock.createApiResponse({ statusCode: 500, body: 'some message' })
);
mockStartParams.session.get.mockRejectedValue(failureReason);
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.getCurrent({ request: mockRequest })).rejects.toBe(failureReason);
expect(mockStartParams.session.get).toHaveBeenCalledTimes(1);
expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest);
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).not.toHaveBeenCalled();
});
it('fails if profile retrieval fails', async () => {
mockStartParams.session.get.mockResolvedValue(
sessionMock.createValue({ userProfileId: mockUserProfile.uid })
);
const failureReason = new errors.ResponseError(
securityMock.createApiResponse({ statusCode: 500, body: 'some message' })
);
mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockRejectedValue(
failureReason
);
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.getCurrent({ request: mockRequest })).rejects.toBe(failureReason);
expect(mockStartParams.session.get).toHaveBeenCalledTimes(1);
expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest);
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).toHaveBeenCalledWith({
@ -101,18 +160,61 @@ describe('UserProfileService', () => {
});
});
it('should handle errors when get user profile fails', async () => {
mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockRejectedValue(
new Error('Fail')
it('properly parses returned profile', async () => {
mockStartParams.session.get.mockResolvedValue(
sessionMock.createValue({ userProfileId: mockUserProfile.uid })
);
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.get('UID')).rejects.toMatchInlineSnapshot(`[Error: Fail]`);
expect(logger.error).toHaveBeenCalled();
await expect(startContract.getCurrent({ request: mockRequest })).resolves
.toMatchInlineSnapshot(`
Object {
"data": Object {},
"enabled": true,
"labels": Object {},
"uid": "UID",
"user": Object {
"display_name": "display-name-1",
"email": undefined,
"full_name": "full-name-1",
"realm_domain": "some-domain",
"realm_name": "some-realm",
"roles": Array [
"role-1",
],
"username": "user-1",
},
}
`);
expect(mockStartParams.session.get).toHaveBeenCalledTimes(1);
expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest);
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).toHaveBeenCalledWith({
uid: 'UID',
});
});
it('should get user profile and application data scoped to Kibana', async () => {
mockStartParams.session.get.mockResolvedValue(
sessionMock.createValue({ userProfileId: mockUserProfile.uid })
);
mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({
[mockUserProfile.uid]: userProfileMock.createWithSecurity({
...mockUserProfile,
data: { kibana: { avatar: 'fun.gif' }, other_app: { secret: 'data' } },
}),
} as unknown as SecurityGetUserProfileResponse);
const startContract = userProfileService.start(mockStartParams);
await expect(startContract.get('UID', '*')).resolves.toMatchInlineSnapshot(`
await expect(startContract.getCurrent({ request: mockRequest, dataPath: '*' })).resolves
.toMatchInlineSnapshot(`
Object {
"data": Object {
"avatar": "fun.gif",
@ -121,16 +223,25 @@ describe('UserProfileService', () => {
"labels": Object {},
"uid": "UID",
"user": Object {
"display_name": undefined,
"email": "some@email",
"full_name": undefined,
"realm_domain": "some-realm-domain",
"display_name": "display-name-1",
"email": undefined,
"full_name": "full-name-1",
"realm_domain": "some-domain",
"realm_name": "some-realm",
"roles": Array [],
"username": "some-username",
"roles": Array [
"role-1",
],
"username": "user-1",
},
}
`);
expect(mockStartParams.session.get).toHaveBeenCalledTimes(1);
expect(mockStartParams.session.get).toHaveBeenCalledWith(mockRequest);
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).toHaveBeenCalledTimes(1);
expect(
mockStartParams.clusterClient.asInternalUser.security.getUserProfile
).toHaveBeenCalledWith({

View file

@ -11,12 +11,20 @@ import type {
} 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 { IClusterClient, KibanaRequest, Logger } from '@kbn/core/server';
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { UserProfile, UserProfileData, UserProfileWithSecurity } from '../../common';
import type {
UserProfile,
UserProfileData,
UserProfileLabels,
UserProfileWithSecurity,
} from '../../common';
import type { AuthorizationServiceSetupInternal } from '../authorization';
import type { CheckUserProfilesPrivilegesResponse } from '../authorization/types';
import { getDetailedErrorMessage, getErrorStatusCode } from '../errors';
import type { Session } from '../session_management';
import { getPrintableSessionId } from '../session_management';
import type { UserProfileGrant } from './user_profile_grant';
const KIBANA_DATA_ROOT = 'kibana';
@ -30,6 +38,18 @@ const MIN_SUGGESTIONS_FOR_PRIVILEGES_CHECK = 10;
* A set of methods to work with Kibana user profiles.
*/
export interface UserProfileServiceStart {
/**
* Retrieves a user profile for the current user extracted from the specified request. If the profile isn't available,
* e.g. for the anonymous users or users authenticated via authenticating proxies, the `null` value is returned.
* @param params Get current user profile operation parameters.
* @param params.request User request instance to get user profile for.
* @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.
*/
getCurrent<D extends UserProfileData, L extends UserProfileLabels>(
params: UserProfileGetCurrentParams
): Promise<UserProfileWithSecurity<D, L> | null>;
/**
* Retrieves multiple user profiles by their identifiers.
* @param params Bulk get operation parameters.
@ -61,17 +81,6 @@ export interface UserProfileServiceStartInternal extends UserProfileServiceStart
*/
activate(grant: UserProfileGrant): Promise<UserProfileWithSecurity>;
/**
* 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<D extends UserProfileData>(
uid: string,
dataPath?: string
): Promise<UserProfileWithSecurity<D>>;
/**
* Updates user preferences by identifier.
* @param uid User ID
@ -86,6 +95,7 @@ export interface UserProfileServiceSetupParams {
export interface UserProfileServiceStartParams {
clusterClient: IClusterClient;
session: PublicMethodsOf<Session>;
}
/**
@ -103,6 +113,22 @@ export interface UserProfileRequiredPrivileges {
privileges: { kibana: string[] };
}
/**
* Parameters for the get user profile for the current user API.
*/
export interface UserProfileGetCurrentParams {
/**
* User request instance to get user profile for.
*/
request: KibanaRequest;
/**
* By default, get 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 bulk get API.
*/
@ -194,10 +220,10 @@ export class UserProfileService {
this.authz = authz;
}
start({ clusterClient }: UserProfileServiceStartParams) {
start({ clusterClient, session }: UserProfileServiceStartParams) {
return {
activate: this.activate.bind(this, clusterClient),
get: this.get.bind(this, clusterClient),
getCurrent: this.getCurrent.bind(this, clusterClient, session),
bulkGet: this.bulkGet.bind(this, clusterClient),
update: this.update.bind(this, clusterClient),
suggest: this.suggest.bind(this, clusterClient),
@ -261,29 +287,52 @@ export class UserProfileService {
}
/**
* See {@link UserProfileServiceStartInternal} for documentation.
* See {@link UserProfileServiceStart} for documentation.
*/
private async get<D extends UserProfileData>(
private async getCurrent<D extends UserProfileData>(
clusterClient: IClusterClient,
uid: string,
dataPath?: string
session: PublicMethodsOf<Session>,
{ request, dataPath }: UserProfileGetCurrentParams
) {
let userSession;
try {
userSession = await session.get(request);
} catch (error) {
this.logger.error(`Failed to retrieve user session: ${getDetailedErrorMessage(error)}`);
throw error;
}
if (!userSession) {
return null;
}
if (!userSession.userProfileId) {
this.logger.debug(
`User profile missing from the current session [sid=${getPrintableSessionId(
userSession.sid
)}].`
);
return null;
}
try {
const body = await clusterClient.asInternalUser.security.getUserProfile({
uid,
uid: userSession.userProfileId,
data: dataPath ? `${KIBANA_DATA_ROOT}.${dataPath}` : undefined,
});
return parseUserProfileWithSecurity<D>(body[uid]!);
return parseUserProfileWithSecurity<D>(body[userSession.userProfileId]!);
} catch (error) {
this.logger.error(
`Failed to retrieve user profile [uid=${uid}]: ${getDetailedErrorMessage(error)}`
`Failed to retrieve user profile for the current user [sid=${getPrintableSessionId(
userSession.sid
)}]: ${getDetailedErrorMessage(error)}`
);
throw error;
}
}
/**
* See {@link UserProfileServiceStartInternal} for documentation.
* See {@link UserProfileServiceStart} for documentation.
*/
private async bulkGet<D extends UserProfileData>(
clusterClient: IClusterClient,
@ -357,7 +406,7 @@ export class UserProfileService {
}
/**
* See {@link UserProfileServiceStartInternal} for documentation.
* See {@link UserProfileServiceStart} for documentation.
*/
private async suggest<D extends UserProfileData>(
clusterClient: IClusterClient,

View file

@ -0,0 +1,139 @@
/*
* 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 { parse as parseCookie } from 'tough-cookie';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertestWithoutAuth = getService('supertestWithoutAuth');
const security = getService('security');
describe('Getting user profile for the current user', () => {
const testUserName = 'user_with_profile';
async function login() {
const response = await supertestWithoutAuth
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic',
currentURL: '/',
params: { username: testUserName, password: 'changeme' },
})
.expect(200);
return parseCookie(response.headers['set-cookie'][0])!;
}
before(async () => {
await security.user.create(testUserName, {
password: 'changeme',
roles: [`viewer`],
full_name: 'User With Profile',
email: 'user_with_profile@elastic.co',
});
});
after(async () => {
await security.user.delete(testUserName);
});
it('can get user profile for the current user', async () => {
const sessionCookie = await login();
await supertestWithoutAuth
.post('/internal/security/user_profile/_data')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.send({ some: 'data', another: 'another-data' })
.expect(200);
const { body: profileWithoutData } = await supertestWithoutAuth
.get('/internal/security/user_profile')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
const { body: profileWithAllData } = await supertestWithoutAuth
.get('/internal/security/user_profile?dataPath=*')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
const { body: profileWithSomeData } = await supertestWithoutAuth
.get('/internal/security/user_profile?dataPath=some')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
// Profile UID is supposed to be stable.
expectSnapshot(profileWithoutData).toMatchInline(`
Object {
"data": Object {},
"enabled": true,
"labels": Object {},
"uid": "u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0",
"user": Object {
"authentication_provider": Object {
"name": "basic",
"type": "basic",
},
"email": "user_with_profile@elastic.co",
"full_name": "User With Profile",
"realm_name": "default_native",
"roles": Array [
"viewer",
],
"username": "user_with_profile",
},
}
`);
expectSnapshot(profileWithAllData).toMatchInline(`
Object {
"data": Object {
"another": "another-data",
"some": "data",
},
"enabled": true,
"labels": Object {},
"uid": "u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0",
"user": Object {
"authentication_provider": Object {
"name": "basic",
"type": "basic",
},
"email": "user_with_profile@elastic.co",
"full_name": "User With Profile",
"realm_name": "default_native",
"roles": Array [
"viewer",
],
"username": "user_with_profile",
},
}
`);
expectSnapshot(profileWithSomeData).toMatchInline(`
Object {
"data": Object {
"some": "data",
},
"enabled": true,
"labels": Object {},
"uid": "u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0",
"user": Object {
"authentication_provider": Object {
"name": "basic",
"type": "basic",
},
"email": "user_with_profile@elastic.co",
"full_name": "User With Profile",
"realm_name": "default_native",
"roles": Array [
"viewer",
],
"username": "user_with_profile",
},
}
`);
});
});
}

View file

@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('security APIs - User Profiles', function () {
loadTestFile(require.resolve('./suggest'));
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./get_current'));
});
}