diff --git a/CHANGELOG.md b/CHANGELOG.md index f251407addf1..12f9a43f16b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Replace OuiSelect component with OuiSuperSelect in data-source plugin ([#5315](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5315)) - [Workspace] Add core workspace service module to enable the implementation of workspace features within OSD plugins ([#5092](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5092)) - [Workspace] Setup workspace skeleton and implement basic CRUD API ([#5075](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5075)) +- [Workspace] Add ACL related functions ([#5084](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5084/)) - [Decouple] Add new cross compatibility check core service which export functionality for plugins to verify if their OpenSearch plugin counterpart is installed on the cluster or has incompatible version to configure the plugin behavior([#4710](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4710)) - [Discover] Add long numerals support [#5592](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5592) - [Discover] Display inner properties in the left navigation bar [#5429](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5429) diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 8de3b4f3f6ec..b2710ad4cba6 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -294,5 +294,10 @@ # Set the value of this setting to true to enable plugin augmentation on Dashboard # 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 \ No newline at end of file +# 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 7ed60b0aa526..8b1f5df9640a 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 {