From f1eb76281e8fc101c9c47c35ba896f3550113cfd Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 14 Sep 2023 17:46:53 +0800 Subject: [PATCH] [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 --- .../build_active_mappings.test.ts.snap | 110 ++++++++ .../migrations/core/build_active_mappings.ts | 20 ++ .../migrations/core/index_migrator.test.ts | 105 ++++++++ ...pensearch_dashboards_migrator.test.ts.snap | 55 ++++ .../permission_control/acl.test.ts | 166 ++++++++++++ .../saved_objects/permission_control/acl.ts | 249 ++++++++++++++++++ 6 files changed, 705 insertions(+) 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 diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index f8ef47cae894..6f67893104e7 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -10,6 +10,7 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -36,6 +37,60 @@ Object { "originId": Object { "type": "keyword", }, + "permissions": Object { + "properties": Object { + "library_read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "library_write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "management": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + }, + }, "references": Object { "properties": Object { "id": Object { @@ -69,6 +124,7 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", @@ -99,6 +155,60 @@ Object { "originId": Object { "type": "keyword", }, + "permissions": Object { + "properties": Object { + "library_read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "library_write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "management": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + }, + }, "references": Object { "properties": Object { "id": Object { 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 bf377a13a42e..02dc13b2cd3f 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 @@ -36,6 +36,7 @@ import crypto from 'crypto'; import { cloneDeep, mapValues } from 'lodash'; import { IndexMapping, + SavedObjectsFieldMapping, SavedObjectsMappingProperties, SavedObjectsTypeMappingDefinitions, } from './../../mappings'; @@ -137,6 +138,16 @@ function findChangedProp(actual: any, expected: any) { * @returns {IndexMapping} */ function defaultMapping(): IndexMapping { + const principals: SavedObjectsFieldMapping = { + properties: { + users: { + type: 'keyword', + }, + groups: { + type: 'keyword', + }, + }, + }; return { dynamic: 'strict', properties: { @@ -175,6 +186,15 @@ function defaultMapping(): IndexMapping { }, }, }, + permissions: { + properties: { + read: principals, + write: principals, + management: principals, + library_read: principals, + library_write: principals, + }, + }, }, }; } 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 4bacfda3bd5a..70f96c2e4daf 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 @@ -79,6 +79,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -92,6 +93,40 @@ describe('IndexMigrator', () => { 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' }, + }, + }, + management: { + 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: { @@ -196,6 +231,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -210,6 +246,40 @@ describe('IndexMigrator', () => { 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' }, + }, + }, + management: { + 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: { @@ -257,6 +327,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -271,6 +342,40 @@ describe('IndexMigrator', () => { 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' }, + }, + }, + management: { + 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: { diff --git a/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap index baebb7848798..5e39af788d79 100644 --- a/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap @@ -10,6 +10,7 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -44,6 +45,60 @@ Object { "originId": Object { "type": "keyword", }, + "permissions": Object { + "properties": Object { + "library_read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "library_write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "management": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + }, + }, "references": Object { "properties": Object { "id": Object { 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..057d294c3637 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.test.ts @@ -0,0 +1,166 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Principals, Permissions, ACL } from './acl'; + +describe('SavedObjectTypeRegistry', () => { + 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); + }); + + it('test add permission', () => { + const acl = new ACL(); + const result1 = acl + .addPermission(['read'], { + users: ['user1'], + groups: [], + }) + .getPermissions(); + expect(result1?.read?.users).toEqual(['user1']); + + acl.resetPermissions(); + const result2 = acl + .addPermission(['write', 'management'], { + users: ['user2'], + groups: ['group1', 'group2'], + }) + .getPermissions(); + expect(result2?.write?.users).toEqual(['user2']); + expect(result2?.management?.groups).toEqual(['group1', 'group2']); + }); + + it('test remove permission', () => { + const principals1: Principals = { + users: ['user1'], + groups: ['group1', 'group2'], + }; + const permissions1 = { + read: principals1, + write: principals1, + }; + const acl1 = new ACL(permissions1); + const result1 = acl1 + .removePermission(['read'], { + users: ['user1'], + groups: [], + }) + .removePermission(['write'], { + users: [], + groups: ['group2'], + }) + .getPermissions(); + expect(result1?.read?.users).toEqual([]); + expect(result1?.write?.groups).toEqual(['group1']); + + const principals2: Principals = { + users: ['*'], + groups: ['*'], + }; + + const permissions2 = { + read: principals2, + write: principals2, + }; + + const acl2 = new ACL(permissions2); + const result2 = acl2 + .removePermission(['read', 'write'], { + users: ['user1'], + groups: ['group1'], + }) + .getPermissions(); + expect(result2?.read?.users).toEqual(['*']); + expect(result2?.write?.groups).toEqual(['*']); + }); + + it('test transform permission', () => { + const principals: Principals = { + users: ['user1'], + groups: ['group1', 'group2'], + }; + const permissions = { + read: principals, + write: principals, + }; + const acl = new ACL(permissions); + const 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'] }]) + ); + }); + + it('test generate query DSL', () => { + const principals = { + users: ['user1'], + groups: ['group1'], + }; + const 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'], + }, + }, + ], + }, + }, + }); + }); +}); 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..1631b0cbef46 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.ts @@ -0,0 +1,249 @@ +/* + * 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?: Principals, users?: string[], groups?: string[]) => { + if (!principals) { + principals = {}; + } + 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?: 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 = (currentPrincipals: Principals | undefined, principals: Principals) => { + return ( + (currentPrincipals?.users && + principals?.users && + checkPermissionForSinglePrincipalType(currentPrincipals.users, principals.users)) || + (currentPrincipals?.groups && + principals.groups && + checkPermissionForSinglePrincipalType(currentPrincipals.groups, principals.groups)) + ); +}; + +const checkPermissionForSinglePrincipalType = ( + currentPrincipalArray: string[], + principalArray: string[] +) => { + return ( + currentPrincipalArray && + principalArray && + (currentPrincipalArray.includes('*') || + principalArray.some((item) => currentPrincipalArray.includes(item))) + ); +}; + +export class ACL { + private permissions?: Permissions; + constructor(initialPermissions?: Permissions) { + this.permissions = initialPermissions || {}; + } + + // parse the permissions object to check whether the specific principal has the specific permission types or not + 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) + ); + } + + // permissions object build function, add principal with specific permission to the object + 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( + this.permissions[permissionType], + principals.users, + principals.groups + ); + } + + return this; + } + + // permissions object build function, remove specific permission of specific principal from the object + public removePermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || !principals) { + return this; + } + if (!this.permissions) { + this.permissions = {}; + } + + for (const permissionType of permissionTypes) { + const result = deleteFromPrincipals( + this.permissions![permissionType], + principals.users, + principals.groups + ); + if (result) { + this.permissions[permissionType] = result; + } + } + + return this; + } + + /** + * transform permissions format + * original permissions: { + * read: { + * users:['user1'] + * }, + * write:{ + * groups:['group1'] + * } + * } + * + * transformed permissions: [ + * {type:'users',name:'user1',permissions:['read']}, + * {type:'groups',name:'group1',permissions:['write']}, + * ] + */ + 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; + } + + public resetPermissions() { + // reset permissions + this.permissions = {}; + } + + // return the permissions object + public getPermissions() { + return this.permissions; + } + + /** + * generate query DSL by the specific conditions, used for fetching saved objects from the saved objects index + */ + 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 } }; + } +}