From 494ff4d0372d7ef4a1fbc80d830f5dfb61ca0fe7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Apr 2024 05:23:36 +0000 Subject: [PATCH] [Workspace][Feature] Add ACL related functions (#5084) * [Workspace] Add ACL related functions for workspace (#146) * [Workspace] Add acl related functions for workspace Signed-off-by: gaobinlong * Minor change Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong * Modify changelog Signed-off-by: gaobinlong * Add more unit test cases Signed-off-by: gaobinlong * Modify test case Signed-off-by: gaobinlong * Some minor change Signed-off-by: gaobinlong * Add more test cases Signed-off-by: gaobinlong * Optimize some code and the comments of the functions Signed-off-by: gaobinlong * Add more comments for some basic functions Signed-off-by: gaobinlong * Export more interfaces Signed-off-by: gaobinlong * consume permissions in repository Signed-off-by: SuZhou-Joe * feat: consume permissions in serializer Signed-off-by: SuZhou-Joe * Add unit tests for consuming permissions in repository Signed-off-by: gaobinlong * Remove double exclamation Signed-off-by: gaobinlong * Rename some variables Signed-off-by: gaobinlong * Remove duplicated semicolon Signed-off-by: gaobinlong * Add permissions field to the mapping only if the permission control is enabled Signed-off-by: gaobinlong * Fix test failure Signed-off-by: gaobinlong * Add feature flag config to the yml file Signed-off-by: gaobinlong * Make the comment of feature flag more clear Signed-off-by: gaobinlong * Make comment more clear Signed-off-by: gaobinlong * Remove management permission type Signed-off-by: gaobinlong * Fix test failure Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong Signed-off-by: SuZhou-Joe Co-authored-by: Josh Romero Co-authored-by: SuZhou-Joe (cherry picked from commit 54c36fec14034eef3221efc5553b651e92370f18) Signed-off-by: github-actions[bot] --- config/opensearch_dashboards.yml | 5 + .../core/build_active_mappings.test.ts | 6 + .../migrations/core/build_active_mappings.ts | 24 ++ .../migrations/core/index_migrator.test.ts | 95 ++++- .../opensearch_dashboards_migrator.test.ts | 29 +- .../permission_control/acl.test.ts | 348 ++++++++++++++++++ .../saved_objects/permission_control/acl.ts | 337 +++++++++++++++++ .../saved_objects/permission_control/index.ts | 6 + .../saved_objects/saved_objects_config.ts | 3 + .../saved_objects_service.test.ts | 9 +- .../saved_objects/serialization/serializer.ts | 5 +- .../saved_objects/serialization/types.ts | 2 + .../service/lib/repository.test.js | 179 ++++++++- .../saved_objects/service/lib/repository.ts | 19 +- .../service/saved_objects_client.ts | 7 +- src/core/types/saved_objects.ts | 3 + 16 files changed, 1049 insertions(+), 28 deletions(-) create mode 100644 src/core/server/saved_objects/permission_control/acl.test.ts create mode 100644 src/core/server/saved_objects/permission_control/acl.ts create mode 100644 src/core/server/saved_objects/permission_control/index.ts diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 150bb5ee9488..5998ee066878 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -306,5 +306,10 @@ # Set the value of this setting to true to enable plugin augmentation # vis_augmenter.pluginAugmentationEnabled: true +# Set the value to true to enable permission control for saved objects +# Permission control depends on OpenSearch Dashboards has authentication enabled, set it to false when the security plugin is not installed, +# if the security plugin is not installed and this config is true, permission control takes no effect. +# savedObjects.permission.enabled: true + # Set the value to true to enable workspace feature # workspace.enabled: false diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts index 4acc161c4bab..5fb3bb3b4c8a 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts @@ -93,6 +93,12 @@ describe('buildActiveMappings', () => { expect(hashes.aaa).not.toEqual(hashes.ccc); }); + test('permissions field is added when permission control flag is enabled', () => { + const rawConfig = configMock.create(); + rawConfig.get.mockReturnValue(true); + expect(buildActiveMappings({}, rawConfig)).toHaveProperty('properties.permissions'); + }); + test('workspaces field is added when workspace feature flag is enabled', () => { const rawConfig = configMock.create(); rawConfig.get.mockReturnValue(true); diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 01a7ba11b707..55b73daabc3e 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -37,6 +37,7 @@ import { cloneDeep, mapValues } from 'lodash'; import { Config } from '@osd/config'; import { IndexMapping, + SavedObjectsFieldMapping, SavedObjectsMappingProperties, SavedObjectsTypeMappingDefinitions, } from './../../mappings'; @@ -55,6 +56,29 @@ export function buildActiveMappings( let mergedProperties = validateAndMerge(mapping.properties, typeDefinitions); // if permission control for saved objects is enabled, the permissions field should be added to the mapping + if (opensearchDashboardsRawConfig?.get('savedObjects.permission.enabled')) { + const principals: SavedObjectsFieldMapping = { + properties: { + users: { + type: 'keyword', + }, + groups: { + type: 'keyword', + }, + }, + }; + mergedProperties = validateAndMerge(mapping.properties, { + permissions: { + properties: { + read: principals, + write: principals, + library_read: principals, + library_write: principals, + }, + }, + }); + } + if (opensearchDashboardsRawConfig?.get('workspace.enabled')) { mergedProperties = validateAndMerge(mapping.properties, { workspaces: { diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index ee1c9a39ff8a..296ca5f29f1e 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -60,12 +60,105 @@ describe('IndexMigrator', () => { }; }); + test('creates the index when permission control for saved objects is enabled', async () => { + const { client } = testOpts; + + testOpts.mappingProperties = { foo: { type: 'long' } as any }; + const rawConfig = configMock.create(); + rawConfig.get.mockImplementation((path) => { + if (path === 'savedObjects.permission.enabled') { + return true; + } else { + return false; + } + }); + testOpts.opensearchDashboardsRawConfig = rawConfig; + + withIndex(client, { index: { statusCode: 404 }, alias: { statusCode: 404 } }); + + await new IndexMigrator(testOpts).migrate(); + + expect(client.indices.create).toHaveBeenCalledWith({ + body: { + mappings: { + dynamic: 'strict', + _meta: { + migrationMappingPropertyHashes: { + foo: '18c78c995965207ed3f6e7fc5c6e55fe', + migrationVersion: '4a1746014a75ade3a714e1db5763276f', + namespace: '2f4316de49999235636386fe51dc06c1', + namespaces: '2f4316de49999235636386fe51dc06c1', + originId: '2f4316de49999235636386fe51dc06c1', + permissions: 'f3ad308fa2a0c34007eb9ad461d6294a', + references: '7997cf5a56cc02bdc9c93361bde732b0', + type: '2f4316de49999235636386fe51dc06c1', + updated_at: '00da57df13e94e9d98437d13ace4bfe0', + }, + }, + properties: { + foo: { type: 'long' }, + migrationVersion: { dynamic: 'true', type: 'object' }, + namespace: { type: 'keyword' }, + namespaces: { type: 'keyword' }, + originId: { type: 'keyword' }, + type: { type: 'keyword' }, + updated_at: { type: 'date' }, + permissions: { + properties: { + library_read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + library_write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + }, + }, + references: { + type: 'nested', + properties: { + name: { type: 'keyword' }, + type: { type: 'keyword' }, + id: { type: 'keyword' }, + }, + }, + }, + }, + settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, + }, + index: '.kibana_1', + }); + }); + test('creates the index when workspaces feature flag is enabled', async () => { const { client } = testOpts; testOpts.mappingProperties = { foo: { type: 'long' } as any }; const rawConfig = configMock.create(); - rawConfig.get.mockReturnValue(true); + rawConfig.get.mockImplementation((path) => { + if (path === 'workspace.enabled') { + return true; + } else { + return false; + } + }); testOpts.opensearchDashboardsRawConfig = rawConfig; withIndex(client, { index: { statusCode: 404 }, alias: { statusCode: 404 } }); diff --git a/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.test.ts b/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.test.ts index b0350a00b211..e65effdd8eaa 100644 --- a/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/opensearch_dashboards/opensearch_dashboards_migrator.test.ts @@ -78,8 +78,14 @@ describe('OpenSearchDashboardsMigrator', () => { expect(mappings).toMatchSnapshot(); }); + it('permissions field exists in the mappings when the feature is enabled', () => { + const options = mockOptions(false, true); + const mappings = new OpenSearchDashboardsMigrator(options).getActiveMappings(); + expect(mappings).toHaveProperty('properties.permissions'); + }); + it('workspaces field exists in the mappings when the feature is enabled', () => { - const options = mockOptions(true); + const options = mockOptions(true, false); const mappings = new OpenSearchDashboardsMigrator(options).getActiveMappings(); expect(mappings).toHaveProperty('properties.workspaces'); }); @@ -153,12 +159,29 @@ type MockedOptions = OpenSearchDashboardsMigratorOptions & { client: ReturnType; }; -const mockOptions = (isWorkspaceEnabled?: boolean) => { +const mockOptions = (isWorkspaceEnabled?: boolean, isPermissionControlEnabled?: boolean) => { const rawConfig = configMock.create(); rawConfig.get.mockReturnValue(false); - if (isWorkspaceEnabled) { + if (isWorkspaceEnabled || isPermissionControlEnabled) { rawConfig.get.mockReturnValue(true); } + rawConfig.get.mockImplementation((path) => { + if (path === 'savedObjects.permission.enabled') { + if (isPermissionControlEnabled) { + return true; + } else { + return false; + } + } else if (path === 'workspace.enabled') { + if (isWorkspaceEnabled) { + return true; + } else { + return false; + } + } else { + return false; + } + }); const options: MockedOptions = { logger: loggingSystemMock.create().get(), opensearchDashboardsVersion: '8.2.3', diff --git a/src/core/server/saved_objects/permission_control/acl.test.ts b/src/core/server/saved_objects/permission_control/acl.test.ts new file mode 100644 index 000000000000..184c10a36aaa --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.test.ts @@ -0,0 +1,348 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Principals, Permissions, ACL } from './acl'; + +describe('acl', () => { + it('test has permission', () => { + const principals: Principals = { + users: ['user1'], + groups: [], + }; + const permissions: Permissions = { + read: principals, + }; + const acl = new ACL(permissions); + expect( + acl.hasPermission(['read'], { + users: ['user1'], + groups: [], + }) + ).toEqual(true); + + expect( + acl.hasPermission(['read'], { + users: ['user2'], + groups: [], + }) + ).toEqual(false); + + expect( + acl.hasPermission([], { + users: ['user2'], + groups: [], + }) + ).toEqual(false); + + const nullValue: unknown = undefined; + expect(acl.hasPermission(['read'], nullValue as Principals)).toEqual(false); + expect(acl.hasPermission(['read'], {})).toEqual(false); + + acl.resetPermissions(); + expect(acl.hasPermission(['read'], nullValue as Principals)).toEqual(false); + expect(acl.hasPermission(['read'], {})).toEqual(false); + expect(acl.hasPermission(['read'], principals)).toEqual(false); + }); + + it('test add permission', () => { + const acl = new ACL(); + let result = acl + .addPermission(['read'], { + users: ['user1'], + groups: [], + }) + .getPermissions(); + expect(result?.read?.users).toEqual(['user1']); + + acl.resetPermissions(); + result = acl + .addPermission(['write', 'library_write'], { + users: ['user2'], + groups: ['group1', 'group2'], + }) + .getPermissions(); + expect(result?.write?.users).toEqual(['user2']); + expect(result?.library_write?.groups).toEqual(['group1', 'group2']); + + acl.resetPermissions(); + result = acl + .addPermission(['write', 'library_write'], { + users: ['user2'], + }) + .addPermission(['write', 'library_write'], { + groups: ['group1'], + }) + .getPermissions(); + expect(result?.write?.users).toEqual(['user2']); + expect(result?.write?.groups).toEqual(['group1']); + expect(result?.library_write?.users).toEqual(['user2']); + expect(result?.library_write?.groups).toEqual(['group1']); + + acl.resetPermissions(); + const nullValue: unknown = undefined; + result = acl.addPermission([], nullValue as Principals).getPermissions(); + expect(result).toEqual({}); + + acl.resetPermissions(); + result = acl.addPermission(nullValue as string[], {} as Principals).getPermissions(); + expect(result).toEqual({}); + }); + + it('test remove permission', () => { + let principals: Principals = { + users: ['user1'], + groups: ['group1', 'group2'], + }; + let permissions = { + read: principals, + write: principals, + }; + let acl = new ACL(permissions); + let result = acl + .removePermission(['read'], { + users: ['user1'], + }) + .removePermission(['write'], { + groups: ['group2'], + }) + .removePermission(['write'], { + users: ['user3'], + groups: ['group3'], + }) + .removePermission(['library_write'], { + users: ['user1'], + groups: ['group1'], + }) + .getPermissions(); + expect(result?.read?.users).toEqual([]); + expect(result?.write?.groups).toEqual(['group1']); + + principals = { + users: ['*'], + groups: ['*'], + }; + permissions = { + read: principals, + write: principals, + }; + acl = new ACL(permissions); + result = acl + .removePermission(['read', 'write'], { + users: ['user1'], + groups: ['group1'], + }) + .getPermissions(); + expect(result?.read?.users).toEqual(['*']); + expect(result?.write?.groups).toEqual(['*']); + + acl.resetPermissions(); + const nullValue: unknown = undefined; + result = acl.removePermission([], nullValue as Principals).getPermissions(); + expect(result).toEqual({}); + + acl.resetPermissions(); + result = acl.removePermission(nullValue as string[], principals).getPermissions(); + expect(result).toEqual({}); + }); + + it('test toFlatList', () => { + let principals: Principals = { + users: ['user1'], + groups: ['group1', 'group2'], + }; + let permissions = { + read: principals, + write: principals, + }; + let acl = new ACL(permissions); + let result = acl.toFlatList(); + expect(result).toHaveLength(3); + expect(result).toEqual( + expect.arrayContaining([{ type: 'users', name: 'user1', permissions: ['read', 'write'] }]) + ); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group1', permissions: ['read', 'write'] }]) + ); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group2', permissions: ['read', 'write'] }]) + ); + + acl.resetPermissions(); + principals = { + users: ['user1'], + }; + permissions = { + read: principals, + write: principals, + }; + acl = new ACL(permissions); + result = acl.toFlatList(); + expect(result).toHaveLength(1); + expect(result).toEqual( + expect.arrayContaining([{ type: 'users', name: 'user1', permissions: ['read', 'write'] }]) + ); + + acl.resetPermissions(); + principals = { + groups: ['group1', 'group2'], + }; + permissions = { + read: principals, + write: principals, + }; + acl = new ACL(permissions); + result = acl.toFlatList(); + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group1', permissions: ['read', 'write'] }]) + ); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group2', permissions: ['read', 'write'] }]) + ); + }); + + it('test generate query DSL', () => { + const nullValue: unknown = undefined; + let result = ACL.generateGetPermittedSavedObjectsQueryDSL(['read'], nullValue as Principals); + expect(result).toEqual({ + query: { + match_none: {}, + }, + }); + + const principals = { + users: ['user1'], + groups: ['group1'], + }; + + result = ACL.generateGetPermittedSavedObjectsQueryDSL(nullValue as string[], principals); + expect(result).toEqual({ + query: { + match_none: {}, + }, + }); + + result = ACL.generateGetPermittedSavedObjectsQueryDSL(['read'], principals, 'workspace'); + expect(result).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + terms: { + 'permissions.read.users': ['user1'], + }, + }, + { + term: { + 'permissions.read.users': '*', + }, + }, + { + terms: { + 'permissions.read.groups': ['group1'], + }, + }, + { + term: { + 'permissions.read.groups': '*', + }, + }, + ], + }, + }, + { + terms: { + type: ['workspace'], + }, + }, + ], + }, + }, + }); + + result = ACL.generateGetPermittedSavedObjectsQueryDSL(['read'], principals, [ + 'workspace', + 'index-pattern', + ]); + expect(result).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + terms: { + 'permissions.read.users': ['user1'], + }, + }, + { + term: { + 'permissions.read.users': '*', + }, + }, + { + terms: { + 'permissions.read.groups': ['group1'], + }, + }, + { + term: { + 'permissions.read.groups': '*', + }, + }, + ], + }, + }, + { + terms: { + type: ['workspace', 'index-pattern'], + }, + }, + ], + }, + }, + }); + + result = ACL.generateGetPermittedSavedObjectsQueryDSL(['read'], principals); + expect(result).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + terms: { + 'permissions.read.users': ['user1'], + }, + }, + { + term: { + 'permissions.read.users': '*', + }, + }, + { + terms: { + 'permissions.read.groups': ['group1'], + }, + }, + { + term: { + 'permissions.read.groups': '*', + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); +}); diff --git a/src/core/server/saved_objects/permission_control/acl.ts b/src/core/server/saved_objects/permission_control/acl.ts new file mode 100644 index 000000000000..769304fe8736 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.ts @@ -0,0 +1,337 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum PrincipalType { + Users = 'users', + Groups = 'groups', +} + +export interface Principals { + users?: string[]; + groups?: string[]; +} + +export type Permissions = Record; + +export interface TransformedPermission { + type: string; + name: string; + permissions: string[]; +} + +const addToPrincipals = ({ + principals = {}, + users, + groups, +}: { + principals: Principals; + users?: string[]; + groups?: string[]; +}) => { + if (users) { + if (!principals.users) { + principals.users = []; + } + principals.users = Array.from(new Set([...principals.users, ...users])); + } + if (groups) { + if (!principals.groups) { + principals.groups = []; + } + principals.groups = Array.from(new Set([...principals.groups, ...groups])); + } + return principals; +}; + +const deleteFromPrincipals = ({ + principals, + users, + groups, +}: { + principals?: Principals; + users?: string[]; + groups?: string[]; +}) => { + if (!principals) { + return principals; + } + if (users && principals.users) { + principals.users = principals.users.filter((item) => !users.includes(item)); + } + if (groups && principals.groups) { + principals.groups = principals.groups.filter((item) => !groups.includes(item)); + } + return principals; +}; + +const checkPermission = ( + allowedPrincipals: Principals | undefined, + requestedPrincipals: Principals +) => { + return ( + (allowedPrincipals?.users && + requestedPrincipals?.users && + checkPermissionForSinglePrincipalType(allowedPrincipals.users, requestedPrincipals.users)) || + (allowedPrincipals?.groups && + requestedPrincipals?.groups && + checkPermissionForSinglePrincipalType(allowedPrincipals.groups, requestedPrincipals.groups)) + ); +}; + +const checkPermissionForSinglePrincipalType = ( + allowedPrincipalArray: string[], + requestedPrincipalArray: string[] +) => { + return ( + allowedPrincipalArray && + requestedPrincipalArray && + (allowedPrincipalArray.includes('*') || + requestedPrincipalArray.some((item) => allowedPrincipalArray.includes(item))) + ); +}; + +export class ACL { + private permissions?: Permissions; + constructor(initialPermissions?: Permissions) { + this.permissions = initialPermissions || {}; + } + + /** + * A function that parses the permissions object to check whether the specific principal has the specific permission types or not + * + * @param {Array} permissionTypes permission types + * @param {Object} principals the users or groups + * @returns true if the principal has the specified permission types, false if the principal has no permission + * + * @public + */ + public hasPermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || permissionTypes.length === 0 || !this.permissions || !principals) { + return false; + } + + const currentPermissions = this.permissions; + return permissionTypes.some((permissionType) => + checkPermission(currentPermissions[permissionType], principals) + ); + } + + /** + * A permissions object build function that adds principal with specific permission to the object + * + * This function is used to contruct a new permissions object or add principals with specified permissions to + * the existing permissions object. The usage is: + * + * const permissionObject = new ACL() + * .addPermission(['write', 'library_write'], { + * users: ['user2'], + * }) + * .addPermission(['write', 'library_write'], { + * groups: ['group1'], + * }) + * .getPermissions(); + * + * @param {Array} permissionTypes the permission types + * @param {Object} principals the users or groups + * @returns the permissions object + * + * @public + */ + public addPermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || !principals) { + return this; + } + if (!this.permissions) { + this.permissions = {}; + } + + for (const permissionType of permissionTypes) { + this.permissions[permissionType] = addToPrincipals({ + principals: this.permissions[permissionType], + users: principals.users, + groups: principals.groups, + }); + } + + return this; + } + + /** + * A permissions object build function that removes specific permission of specific principal from the object + * + * This function is used to remove principals with specified permissions to + * the existing permissions object. The usage is: + * + * const newPermissionObject = new ACL() + * .removePermission(['write', 'library_write'], { + * users: ['user2'], + * }) + * .removePermission(['write', 'library_write'], { + * groups: ['group1'], + * }) + * .getPermissions(); + * + * @param {Array} permissionTypes the permission types + * @param {Object} principals the users or groups + * @returns the permissions object + * + * @public + */ + public removePermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || !principals) { + return this; + } + if (!this.permissions) { + this.permissions = {}; + } + + for (const permissionType of permissionTypes) { + const result = deleteFromPrincipals({ + principals: this.permissions![permissionType], + users: principals.users, + groups: principals.groups, + }); + if (result) { + this.permissions[permissionType] = result; + } + } + + return this; + } + + /** + * A function that transforms permissions format, change the format from permissionType->principals to principal->permissionTypes, + * which is used to clearyly dispaly user/group list and their granted permissions in the UI + * + * for example: + * the original permissions object is: { + * read: { + * users:['user1'] + * }, + * write:{ + * groups:['group1'] + * } + * } + * + * the transformed permissions object will be: [ + * {type:'users', name:'user1', permissions:['read']}, + * {type:'groups', name:'group1', permissions:['write']}, + * ] + * + * @returns the flat list of the permissions object + * + * @public + */ + public toFlatList(): TransformedPermission[] { + const result: TransformedPermission[] = []; + if (!this.permissions) { + return result; + } + + for (const permissionType in this.permissions) { + if (Object.prototype.hasOwnProperty.call(this.permissions, permissionType)) { + const { users = [], groups = [] } = this.permissions[permissionType] ?? {}; + users.forEach((user) => { + const found = result.find((r) => r.type === PrincipalType.Users && r.name === user); + if (found) { + found.permissions.push(permissionType); + } else { + result.push({ type: PrincipalType.Users, name: user, permissions: [permissionType] }); + } + }); + groups.forEach((group) => { + const found = result.find((r) => r.type === PrincipalType.Groups && r.name === group); + if (found) { + found.permissions.push(permissionType); + } else { + result.push({ type: PrincipalType.Groups, name: group, permissions: [permissionType] }); + } + }); + } + } + + return result; + } + + /** + * A permissions object build function that resets the permissions object + * + * @public + */ + public resetPermissions() { + // reset permissions + this.permissions = {}; + } + + /** + * A function that gets the premissions object + * + * @public + */ + public getPermissions() { + return this.permissions; + } + + /** + * A function that generates query DSL by the specific conditions, used for fetching saved objects from the saved objects index + * + * @param {Array} permissionTypes the permission types + * @param {Object} principals the users or groups + * @param {String | Array} savedObjectType saved object type, such as workspace, index-pattern etc. + * @returns the generated query DSL + * + * @public + * @static + */ + public static generateGetPermittedSavedObjectsQueryDSL( + permissionTypes: string[], + principals: Principals, + savedObjectType?: string | string[] + ) { + if (!principals || !permissionTypes) { + return { + query: { + match_none: {}, + }, + }; + } + + const bool: any = { + filter: [], + }; + const subBool: any = { + should: [], + }; + + permissionTypes.forEach((permissionType) => { + Object.entries(principals).forEach(([principalType, principalsInCurrentType]) => { + subBool.should.push({ + terms: { + ['permissions.' + permissionType + `.${principalType}`]: principalsInCurrentType, + }, + }); + subBool.should.push({ + term: { + ['permissions.' + permissionType + `.${principalType}`]: '*', + }, + }); + }); + }); + + bool.filter.push({ + bool: subBool, + }); + + if (savedObjectType) { + bool.filter.push({ + terms: { + type: Array.isArray(savedObjectType) ? savedObjectType : [savedObjectType], + }, + }); + } + + return { query: { bool } }; + } +} diff --git a/src/core/server/saved_objects/permission_control/index.ts b/src/core/server/saved_objects/permission_control/index.ts new file mode 100644 index 000000000000..f0e41a125b1c --- /dev/null +++ b/src/core/server/saved_objects/permission_control/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ACL, Permissions, Principals, PrincipalType, TransformedPermission } from './acl'; diff --git a/src/core/server/saved_objects/saved_objects_config.ts b/src/core/server/saved_objects/saved_objects_config.ts index 291350bf93a6..e6ffaefb8a59 100644 --- a/src/core/server/saved_objects/saved_objects_config.ts +++ b/src/core/server/saved_objects/saved_objects_config.ts @@ -49,6 +49,9 @@ export const savedObjectsConfig = { schema: schema.object({ maxImportPayloadBytes: schema.byteSize({ defaultValue: 26214400 }), maxImportExportSize: schema.byteSize({ defaultValue: 10000 }), + permission: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), }), }; diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 75b0d756f0cf..02eaff20331c 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -41,7 +41,7 @@ import { errors as opensearchErrors } from '@opensearch-project/opensearch'; import { SavedObjectsService } from './saved_objects_service'; import { mockCoreContext } from '../core_context.mock'; -import { Env } from '../config'; +import { Config, Env, ObjectToConfigAdapter } from '../config'; import { configServiceMock, savedObjectsRepositoryMock } from '../mocks'; import { opensearchServiceMock } from '../opensearch/opensearch_service.mock'; import { opensearchClientMock } from '../opensearch/client/mocks'; @@ -70,6 +70,13 @@ describe('SavedObjectsService', () => { maxImportExportSize: new ByteSizeValue(0), }); }); + const config$ = new BehaviorSubject( + new ObjectToConfigAdapter({ + savedObjects: { permission: { enabled: true } }, + }) + ); + + configService.getConfig$.mockReturnValue(config$); return mockCoreContext.create({ configService, env }); }; diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 5c3e22ac646a..9aa6aca713f0 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -73,7 +73,7 @@ export class SavedObjectsSerializer { */ public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace, namespaces, originId, workspaces } = _source; + const { type, namespace, namespaces, originId, workspaces, permissions } = _source; const version = _seq_no != null || _primary_term != null @@ -86,6 +86,7 @@ export class SavedObjectsSerializer { ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), ...(originId && { originId }), + ...(permissions && { permissions }), attributes: _source[type], references: _source.references || [], ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), @@ -114,6 +115,7 @@ export class SavedObjectsSerializer { version, references, workspaces, + permissions, } = savedObj; const source = { [type]: attributes, @@ -125,6 +127,7 @@ export class SavedObjectsSerializer { ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), ...(workspaces && { workspaces }), + ...(permissions && { permissions }), }; return { diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 473a63cf65f4..f882596ce529 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -28,6 +28,7 @@ * under the License. */ +import { Permissions } from '../permission_control'; import { SavedObjectsMigrationVersion, SavedObjectReference } from '../types'; /** @@ -71,6 +72,7 @@ interface SavedObjectDoc { updated_at?: string; originId?: string; workspaces?: string[]; + permissions?: Permissions; } interface Referencable { diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index e50332ae514a..b793046d9a94 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -168,7 +168,7 @@ describe('SavedObjectsRepository', () => { }); const getMockGetResponse = ( - { type, id, references, namespace: objectNamespace, originId }, + { type, id, references, namespace: objectNamespace, originId, permissions }, namespace ) => { const namespaceId = objectNamespace === 'default' ? undefined : objectNamespace ?? namespace; @@ -183,6 +183,7 @@ describe('SavedObjectsRepository', () => { ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), ...(originId && { originId }), + ...(permissions && { permissions }), type, [type]: { title: 'Testing' }, references, @@ -444,25 +445,36 @@ describe('SavedObjectsRepository', () => { references: [{ name: 'ref_0', type: 'test', id: '2' }], }; const namespace = 'foo-namespace'; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const workspace = 'foo-workspace'; const getMockBulkCreateResponse = (objects, namespace) => { return { - items: objects.map(({ type, id, originId, attributes, references, migrationVersion }) => ({ - create: { - _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, - _source: { - [type]: attributes, - type, - namespace, - ...(originId && { originId }), - references, - ...mockTimestampFields, - migrationVersion: migrationVersion || { [type]: '1.1.1' }, + items: objects.map( + ({ type, id, originId, attributes, references, migrationVersion, permissions }) => ({ + create: { + _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, + _source: { + [type]: attributes, + type, + namespace, + ...(originId && { originId }), + ...(permissions && { permissions }), + references, + ...mockTimestampFields, + migrationVersion: migrationVersion || { [type]: '1.1.1' }, + }, + ...mockVersionProps, }, - ...mockVersionProps, - }, - })), + }) + ), }; }; @@ -732,6 +744,18 @@ describe('SavedObjectsRepository', () => { expectClientCallArgsAction(objects, { method: 'create', getId }); }); + it(`accepts permissions property when providing permissions info`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); + await bulkCreateSuccess(objects); + const expected = expect.objectContaining({ permissions }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }); + it(`adds workspaces to request body for any types`, async () => { await bulkCreateSuccess([obj1, obj2], { workspaces: [workspace] }); const expected = expect.objectContaining({ workspaces: [workspace] }); @@ -1011,6 +1035,17 @@ describe('SavedObjectsRepository', () => { ); expect(result.saved_objects[1].id).toEqual(obj2.id); }); + + it(`includes permissions property if present`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); + const result = await bulkCreateSuccess(objects); + expect(result).toEqual({ + saved_objects: [ + expect.objectContaining({ permissions }), + expect.objectContaining({ permissions }), + ], + }); + }); }); }); @@ -1230,6 +1265,22 @@ describe('SavedObjectsRepository', () => { ], }); }); + + it(`includes permissions property if present`, async () => { + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; + const obj = { id: 'three', type: MULTI_NAMESPACE_TYPE, permissions: permissions }; + const result = await bulkGetSuccess([obj]); + expect(result).toEqual({ + saved_objects: [expect.objectContaining({ permissions: permissions })], + }); + }); }); }); @@ -1247,6 +1298,14 @@ describe('SavedObjectsRepository', () => { const references = [{ name: 'ref_0', type: 'test', id: '1' }]; const originId = 'some-origin-id'; const namespace = 'foo-namespace'; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const getMockBulkUpdateResponse = (objects, options, includeOriginId) => ({ items: objects.map(({ type, id }) => ({ @@ -1507,6 +1566,20 @@ describe('SavedObjectsRepository', () => { await bulkUpdateSuccess([{ ..._obj2, namespace }]); expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }, 2); }); + + it(`accepts permissions property when providing permissions info`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); + await bulkUpdateSuccess(objects); + const doc = { + doc: expect.objectContaining({ permissions }), + }; + const body = [expect.any(Object), doc, expect.any(Object), doc]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }); }); describe('errors', () => { @@ -1699,6 +1772,14 @@ describe('SavedObjectsRepository', () => { ], }); }); + + it(`includes permissions property if present`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three', permissions: permissions }; + const result = await bulkUpdateSuccess([obj1, obj], {}, true); + expect(result).toEqual({ + saved_objects: [expect.anything(), expect.objectContaining({ permissions })], + }); + }); }); }); @@ -1854,6 +1935,14 @@ describe('SavedObjectsRepository', () => { id: '123', }, ]; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const createSuccess = async (type, attributes, options) => { const result = await savedObjectsRepository.create(type, attributes, options); @@ -2051,6 +2140,16 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`accepts permissions property`, async () => { + await createSuccess(type, attributes, { id, permissions }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ permissions }), + }), + expect.anything() + ); + }); }); describe('errors', () => { @@ -2131,6 +2230,11 @@ describe('SavedObjectsRepository', () => { expect(serializer.savedObjectToRaw).toHaveBeenLastCalledWith(migratedDoc); }); + it(`adds permissions to body when providing permissions info`, async () => { + await createSuccess(type, attributes, { id, permissions }); + expectMigrationArgs({ permissions }); + }); + it(`adds namespace to body when providing namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id, namespace }); expectMigrationArgs({ namespace }); @@ -2177,11 +2281,13 @@ describe('SavedObjectsRepository', () => { namespace, references, originId, + permissions, }); expect(result).toEqual({ type, id, originId, + permissions, ...mockTimestampFields, version: mockVersion, attributes, @@ -2971,7 +3077,7 @@ describe('SavedObjectsRepository', () => { const namespace = 'foo-namespace'; const originId = 'some-origin-id'; - const getSuccess = async (type, id, options, includeOriginId) => { + const getSuccess = async (type, id, options, includeOriginId, permissions) => { const response = getMockGetResponse( { type, @@ -2979,6 +3085,7 @@ describe('SavedObjectsRepository', () => { // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. ...(includeOriginId && { originId }), + ...(permissions && { permissions }), }, options?.namespace ); @@ -3129,6 +3236,21 @@ describe('SavedObjectsRepository', () => { const result = await getSuccess(type, id, {}, true); expect(result).toMatchObject({ originId }); }); + + it(`includes permissions property if present`, async () => { + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; + const result = await getSuccess(type, id, { namespace }, undefined, permissions); + expect(result).toMatchObject({ + permissions: permissions, + }); + }); }); }); @@ -3730,6 +3852,14 @@ describe('SavedObjectsRepository', () => { }, ]; const originId = 'some-origin-id'; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const updateSuccess = async (type, id, attributes, options, includeOriginId) => { if (registry.isMultiNamespace(type)) { @@ -3906,6 +4036,18 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`accepts permissions when providing permissions info`, async () => { + await updateSuccess(type, id, attributes, { permissions }); + const expected = expect.objectContaining({ permissions }); + const body = { + doc: expected, + }; + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); }); describe('errors', () => { @@ -4000,6 +4142,11 @@ describe('SavedObjectsRepository', () => { const result = await updateSuccess(type, id, attributes, {}, true); expect(result).toMatchObject({ originId }); }); + + it(`includes permissions property if present`, async () => { + const result = await updateSuccess(type, id, attributes, { permissions }); + expect(result).toMatchObject({ permissions }); + }); }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 1a4feab322b3..5340008f06a6 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -244,6 +244,7 @@ export class SavedObjectsRepository { initialNamespaces, version, workspaces, + permissions, } = options; const namespace = normalizeNamespace(options.namespace); @@ -291,6 +292,7 @@ export class SavedObjectsRepository { updated_at: time, ...(Array.isArray(references) && { references }), ...(Array.isArray(workspaces) && { workspaces }), + ...(permissions && { permissions }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); @@ -461,6 +463,7 @@ export class SavedObjectsRepository { references: object.references || [], originId: object.originId, ...(savedObjectWorkspaces && { workspaces: savedObjectWorkspaces }), + ...(object.permissions && { permissions: object.permissions }), }) as SavedObjectSanitizedDoc ), }; @@ -987,7 +990,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId, updated_at: updatedAt } = body._source; + const { originId, updated_at: updatedAt, permissions } = body._source; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -1002,6 +1005,7 @@ export class SavedObjectsRepository { namespaces, ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), + ...(permissions && { permissions }), version: encodeHitVersion(body), attributes: body._source[type], references: body._source.references || [], @@ -1030,7 +1034,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { version, references, refresh = DEFAULT_REFRESH_SETTING } = options; + const { version, references, refresh = DEFAULT_REFRESH_SETTING, permissions } = options; const namespace = normalizeNamespace(options.namespace); let preflightResult: SavedObjectsRawDoc | undefined; @@ -1044,6 +1048,7 @@ export class SavedObjectsRepository { [type]: attributes, updated_at: time, ...(Array.isArray(references) && { references }), + ...(permissions && { permissions }), }; const { body, statusCode } = await this.client.update( @@ -1081,6 +1086,7 @@ export class SavedObjectsRepository { version: encodeHitVersion(body), namespaces, ...(originId && { originId }), + ...(permissions && { permissions }), references, attributes, }; @@ -1281,7 +1287,7 @@ export class SavedObjectsRepository { }; } - const { attributes, references, version, namespace: objectNamespace } = object; + const { attributes, references, version, namespace: objectNamespace, permissions } = object; if (objectNamespace === ALL_NAMESPACES_STRING) { return { @@ -1302,6 +1308,7 @@ export class SavedObjectsRepository { [type]: attributes, updated_at: time, ...(Array.isArray(references) && { references }), + ...(permissions && { permissions }), }; const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type); @@ -1454,7 +1461,7 @@ export class SavedObjectsRepository { )[0] as any; // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at } = documentToSave; + const { [type]: attributes, references, updated_at, permissions } = documentToSave; if (error) { return { id, @@ -1473,6 +1480,7 @@ export class SavedObjectsRepository { version: encodeVersion(seqNo, primaryTerm), attributes, references, + ...(permissions && { permissions }), }; }), }; @@ -1765,7 +1773,7 @@ function getSavedObjectFromSource( id: string, doc: { _seq_no?: number; _primary_term?: number; _source: SavedObjectsRawDocSource } ): SavedObject { - const { originId, updated_at: updatedAt, workspaces } = doc._source; + const { originId, updated_at: updatedAt, workspaces, permissions } = doc._source; let namespaces: string[] = []; if (!registry.isNamespaceAgnostic(type)) { @@ -1785,6 +1793,7 @@ function getSavedObjectFromSource( attributes: doc._source[type], references: doc._source.references || [], migrationVersion: doc._source.migrationVersion, + ...(permissions && { permissions }), }; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index d3edb0d98845..49ce55c824d3 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -39,6 +39,7 @@ import { SavedObjectsFindOptions, } from '../types'; import { SavedObjectsErrorHelpers } from './lib/errors'; +import { Permissions } from '../permission_control'; /** * @@ -72,6 +73,8 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * workspaces the new created objects belong to */ workspaces?: string[]; + /** permission control describe by ACL object */ + permissions?: Permissions; } /** @@ -106,7 +109,7 @@ export interface SavedObjectsBulkCreateObject { * @public */ export interface SavedObjectsBulkUpdateObject - extends Pick { + extends Pick { /** The ID of this Saved Object, guaranteed to be unique for all objects of the same `type` */ id: string; /** The type of this Saved Object. Each plugin can define it's own custom Saved Object types. */ @@ -188,6 +191,8 @@ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { references?: SavedObjectReference[]; /** The OpenSearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; + /** permission control describe by ACL object */ + permissions?: Permissions; } /** diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index a683863d8df6..06d03f5f24c4 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -27,6 +27,7 @@ * specific language governing permissions and limitations * under the License. */ +import { Permissions } from '../server/saved_objects/permission_control/acl'; /** * Don't use this type, it's simply a helper type for {@link SavedObjectAttribute} @@ -115,6 +116,8 @@ export interface SavedObject { originId?: string; /** Workspace(s) that this saved object exists in. */ workspaces?: string[]; + /** Permissions that this saved objects exists in. */ + permissions?: Permissions; } export interface SavedObjectError {