diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 14397456afd..bf89d14ddc5 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -74,6 +74,7 @@ export { RouteValidationResultFactory, DestructiveRouteMethod, SafeRouteMethod, + ensureRawRequest, } from './router'; export { BasePathProxyServer } from './base_path_proxy_server'; export { OnPreRoutingHandler, OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 736f12ab28f..9f1f47a312f 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -220,6 +220,7 @@ export { SessionStorageFactory, DestructiveRouteMethod, SafeRouteMethod, + ensureRawRequest, } from './http'; export { @@ -322,6 +323,10 @@ export { importSavedObjectsFromStream, resolveSavedObjectsImportErrors, SavedObjectsDeleteByWorkspaceOptions, + ACL, + Principals, + TransformedPermission, + PrincipalType, } from './saved_objects'; export { diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 06b2b65fd18..11809c5b88c 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -84,3 +84,11 @@ export { export { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects_config'; export { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; + +export { + Permissions, + ACL, + Principals, + TransformedPermission, + PrincipalType, +} from './permission_control/acl'; diff --git a/src/plugins/workspace/server/permission_control/client.mock.ts b/src/plugins/workspace/server/permission_control/client.mock.ts new file mode 100644 index 00000000000..687e93de1d7 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.mock.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { SavedObjectsPermissionControlContract } from './client'; + +export const savedObjectsPermissionControlMock: SavedObjectsPermissionControlContract = { + validate: jest.fn(), + batchValidate: jest.fn(), + getPrincipalsOfObjects: jest.fn(), + setup: jest.fn(), +}; diff --git a/src/plugins/workspace/server/permission_control/client.test.ts b/src/plugins/workspace/server/permission_control/client.test.ts new file mode 100644 index 00000000000..d2025d90b7c --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { loggerMock } from '@osd/logging/target/mocks'; +import { SavedObjectsPermissionControl } from './client'; +import { httpServerMock, savedObjectsClientMock } from '../../../../core/server/mocks'; + +describe('workspace utils', () => { + it('should return principals when calling getPrincipalsOfObjects', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + getScopedClient.mockImplementation((request) => { + const clientMock = savedObjectsClientMock.create(); + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'foo', + permissions: { + read: { + users: ['foo_user'], + }, + }, + }, + ], + }); + return clientMock; + }); + permissionControlClient.setup(getScopedClient); + const result = await permissionControlClient.getPrincipalsOfObjects( + httpServerMock.createOpenSearchDashboardsRequest(), + [] + ); + expect(result).toEqual({ + foo: [ + { + type: 'users', + name: 'foo_user', + permissions: ['read'], + }, + ], + }); + }); + + it('validate should return error when no saved objects can be found', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient); + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [], + }); + const result = await permissionControlClient.validate( + httpServerMock.createOpenSearchDashboardsRequest(), + { id: 'foo', type: 'dashboard' }, + ['read'] + ); + expect(result.success).toEqual(false); + }); + + it('validate should return error when bulkGet return error', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient); + + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'foo', + type: 'dashboard', + references: [], + attributes: {}, + error: { + error: 'error_bar', + message: 'error_bar', + statusCode: 500, + }, + }, + ], + }); + const errorResult = await permissionControlClient.validate( + httpServerMock.createOpenSearchDashboardsRequest(), + { id: 'foo', type: 'dashboard' }, + ['read'] + ); + expect(errorResult.success).toEqual(false); + expect(errorResult.error).toEqual('error_bar'); + }); + + it('validate should return success normally', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient); + + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'foo', + type: 'dashboard', + references: [], + attributes: {}, + }, + { + id: 'bar', + type: 'dashboard', + references: [], + attributes: {}, + permissions: { + read: { + users: ['bar'], + }, + }, + }, + ], + }); + const batchValidateResult = await permissionControlClient.batchValidate( + httpServerMock.createOpenSearchDashboardsRequest({ + auth: { + credentials: { + authInfo: { + user_name: 'bar', + }, + }, + } as any, + }), + [], + ['read'] + ); + expect(batchValidateResult.success).toEqual(true); + expect(batchValidateResult.result).toEqual(true); + }); +}); diff --git a/src/plugins/workspace/server/permission_control/client.ts b/src/plugins/workspace/server/permission_control/client.ts new file mode 100644 index 00000000000..4d73523ce0b --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.ts @@ -0,0 +1,136 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { OpenSearchDashboardsRequest } from '../../../../core/server'; +import { + ACL, + TransformedPermission, + SavedObjectsBulkGetObject, + SavedObjectsServiceStart, + Logger, +} from '../../../../core/server'; +import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../../common/constants'; +import { getPrincipalsFromRequest } from '../utils'; + +export type SavedObjectsPermissionControlContract = Pick< + SavedObjectsPermissionControl, + keyof SavedObjectsPermissionControl +>; + +export type SavedObjectsPermissionModes = string[]; + +export class SavedObjectsPermissionControl { + private readonly logger: Logger; + private _getScopedClient?: SavedObjectsServiceStart['getScopedClient']; + private getScopedClient(request: OpenSearchDashboardsRequest) { + return this._getScopedClient?.(request, { + excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + }); + } + + constructor(logger: Logger) { + this.logger = logger; + } + + private async bulkGetSavedObjects( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[] + ) { + return (await this.getScopedClient?.(request)?.bulkGet(savedObjects))?.saved_objects || []; + } + public async setup(getScopedClient: SavedObjectsServiceStart['getScopedClient']) { + this._getScopedClient = getScopedClient; + } + public async validate( + request: OpenSearchDashboardsRequest, + savedObject: SavedObjectsBulkGetObject, + permissionModes: SavedObjectsPermissionModes + ) { + return await this.batchValidate(request, [savedObject], permissionModes); + } + + /** + * In batch validate case, the logic is a.withPermission && b.withPermission + * @param request + * @param savedObjects + * @param permissionModes + * @returns + */ + public async batchValidate( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[], + permissionModes: SavedObjectsPermissionModes + ) { + const savedObjectsGet = await this.bulkGetSavedObjects(request, savedObjects); + if (!savedObjectsGet.length) { + return { + success: false, + error: i18n.translate('savedObjects.permission.notFound', { + defaultMessage: 'Can not find target saved objects.', + }), + }; + } + + if (savedObjectsGet.some((item) => item.error)) { + return { + success: false, + error: savedObjectsGet + .filter((item) => item.error) + .map((item) => item.error?.error) + .join('\n'), + }; + } + + const principals = getPrincipalsFromRequest(request); + let savedObjectsBasicInfo: any[] = []; + const hasAllPermission = savedObjectsGet.every((item) => { + // for object that doesn't contain ACL like config, return true + if (!item.permissions) { + return true; + } + const aclInstance = new ACL(item.permissions); + const hasPermission = aclInstance.hasPermission(permissionModes, principals); + if (!hasPermission) { + savedObjectsBasicInfo = [ + ...savedObjectsBasicInfo, + { + id: item.id, + type: item.type, + workspaces: item.workspaces, + permissions: item.permissions, + }, + ]; + } + return hasPermission; + }); + if (!hasAllPermission) { + this.logger.debug( + `Authorization failed, principals: ${JSON.stringify( + principals + )} has no [${permissionModes}] permissions on the requested saved object: ${JSON.stringify( + savedObjectsBasicInfo + )}` + ); + } + return { + success: true, + result: hasAllPermission, + }; + } + + public async getPrincipalsOfObjects( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[] + ): Promise> { + const detailedSavedObjects = await this.bulkGetSavedObjects(request, savedObjects); + return detailedSavedObjects.reduce((total, current) => { + return { + ...total, + [current.id]: new ACL(current.permissions).toFlatList(), + }; + }, {}); + } +} diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 0f60597a7a8..6bcbb52407b 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -117,3 +117,8 @@ export type IResponse = success: false; error?: string; }; + +export interface AuthInfo { + backend_roles?: string[]; + user_name?: string; +} diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index 119b8889f71..159ab49f180 100644 --- a/src/plugins/workspace/server/utils.test.ts +++ b/src/plugins/workspace/server/utils.test.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { generateRandomId } from './utils'; +import { httpServerMock } from '../../../core/server/mocks'; +import { generateRandomId, getPrincipalsFromRequest } from './utils'; describe('workspace utils', () => { it('should generate id with the specified size', () => { @@ -18,4 +19,39 @@ describe('workspace utils', () => { } expect(ids.size).toBe(NUM_OF_ID); }); + + it('should return empty map when request do not have authentication', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const result = getPrincipalsFromRequest(mockRequest); + expect(result).toEqual({}); + }); + + it('should return normally when request has authentication', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + auth: { + credentials: { + authInfo: { + backend_roles: ['foo'], + user_name: 'bar', + }, + }, + } as any, + }); + const result = getPrincipalsFromRequest(mockRequest); + expect(result.users).toEqual(['bar']); + expect(result.groups).toEqual(['foo']); + }); + + it('should return a fake user when there is auth field but no backend_roles or user name', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + auth: { + credentials: { + authInfo: {}, + }, + } as any, + }); + const result = getPrincipalsFromRequest(mockRequest); + expect(result.users?.[0].startsWith('_user_fake_')).toEqual(true); + expect(result.groups).toEqual(undefined); + }); }); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index 89bfabd5265..d2ff66e8b48 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -4,6 +4,13 @@ */ import crypto from 'crypto'; +import { + ensureRawRequest, + OpenSearchDashboardsRequest, + Principals, + PrincipalType, +} from '../../../core/server'; +import { AuthInfo } from './types'; /** * Generate URL friendly random ID @@ -11,3 +18,31 @@ import crypto from 'crypto'; export const generateRandomId = (size: number) => { return crypto.randomBytes(size).toString('base64url').slice(0, size); }; + +export const getPrincipalsFromRequest = (request: OpenSearchDashboardsRequest): Principals => { + const rawRequest = ensureRawRequest(request); + const authInfo = rawRequest?.auth?.credentials?.authInfo as AuthInfo | null; + const payload: Principals = {}; + if (!authInfo) { + /** + * Login user have access to all the workspaces when no authentication is presented. + * The logic will be used when users create workspaces with authentication enabled but turn off authentication for any reason. + */ + return payload; + } + if (!authInfo?.backend_roles?.length && !authInfo.user_name) { + /** + * It means OSD can not recognize who the user is even if authentication is enabled, + * use a fake user that won't be granted permission explicitly. + */ + payload[PrincipalType.Users] = [`_user_fake_${Date.now()}_`]; + return payload; + } + if (authInfo?.backend_roles) { + payload[PrincipalType.Groups] = authInfo.backend_roles; + } + if (authInfo?.user_name) { + payload[PrincipalType.Users] = [authInfo.user_name]; + } + return payload; +};