Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DataSource] Adding an optional setting that controls the permissions of data source to create/update/delete. #7091

Closed
2 changes: 2 additions & 0 deletions changelogs/fragments/7091.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- [DataSource] Add a flag to control the edit permission of datasource. ([#7091](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7091))
6 changes: 6 additions & 0 deletions config/opensearch_dashboards.yml
Original file line number Diff line number Diff line change
@@ -321,6 +321,12 @@
# AWSSigV4:
# enabled: true

# Optional setting that controls the permissions of data source to create, update and delete.
# "read_only": The data source is readonly for all users including OSD admin.
# "admin_only": The data source can only be edited by OSD admin.
# "none": The data source can be edited by all users. Default to "none".
# data_source.editMode: "none"

# Set the value of this setting to false to hide the help menu link to the OpenSearch Dashboards user survey
# opensearchDashboards.survey.url: "https://survey.opensearch.org"

6 changes: 6 additions & 0 deletions src/plugins/data_source/common/data_sources/types.ts
Original file line number Diff line number Diff line change
@@ -60,3 +60,9 @@ export enum DataSourceEngineType {
Elasticsearch = 'Elasticsearch',
NA = 'No Engine Type Available',
}

export enum EditMode {
ReadOnly = 'read_only',
AdminOnly = 'admin_only',
None = 'none',
}
1 change: 1 addition & 0 deletions src/plugins/data_source/common/index.ts
Original file line number Diff line number Diff line change
@@ -6,3 +6,4 @@
export const PLUGIN_ID = 'dataSource';
export const PLUGIN_NAME = 'data_source';
export const DATA_SOURCE_SAVED_OBJECT_TYPE = 'data-source';
export const DATA_SOURCE_PERMISSION_CLIENT_WRAPPER_ID = 'data-source-permission';
4 changes: 4 additions & 0 deletions src/plugins/data_source/config.ts
Original file line number Diff line number Diff line change
@@ -59,6 +59,10 @@ export const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }),
}),
}),
editMode: schema.oneOf(
[schema.literal('read_only'), schema.literal('admin_only'), schema.literal('none')],
{ defaultValue: 'none' }
),
});

export type DataSourcePluginConfigType = TypeOf<typeof configSchema>;
22 changes: 20 additions & 2 deletions src/plugins/data_source/server/plugin.ts
Original file line number Diff line number Diff line change
@@ -22,9 +22,13 @@ import { DataSourcePluginConfigType } from '../config';
import { LoggingAuditor } from './audit/logging_auditor';
import { CryptographyService, CryptographyServiceSetup } from './cryptography_service';
import { DataSourceService, DataSourceServiceSetup } from './data_source_service';
import { DataSourceSavedObjectsClientWrapper, dataSource } from './saved_objects';
import {
DataSourceSavedObjectsClientWrapper,
dataSource,
DataSourcePermissionClientWrapper,
} from './saved_objects';
import { AuthenticationMethod, DataSourcePluginSetup, DataSourcePluginStart } from './types';
import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../common';
import { DATA_SOURCE_PERMISSION_CLIENT_WRAPPER_ID, DATA_SOURCE_SAVED_OBJECT_TYPE } from '../common';

// eslint-disable-next-line @osd/eslint/no-restricted-paths
import { ensureRawRequest } from '../../../../src/core/server/http/router';
@@ -33,6 +37,7 @@ import { registerTestConnectionRoute } from './routes/test_connection';
import { registerFetchDataSourceMetaDataRoute } from './routes/fetch_data_source_metadata';
import { AuthenticationMethodRegistry, IAuthenticationMethodRegistry } from './auth_registry';
import { CustomApiSchemaRegistry } from './schema_registry';
import { EditMode } from '../common/data_sources';

export class DataSourcePlugin implements Plugin<DataSourcePluginSetup, DataSourcePluginStart> {
private readonly logger: Logger;
@@ -67,6 +72,19 @@ export class DataSourcePlugin implements Plugin<DataSourcePluginSetup, DataSourc
return dataSourcePluginStart.getAuthenticationMethodRegistry();
});

const { editMode } = config;

if (editMode && editMode !== EditMode.None) {
const dataSourcePermissionWrapper = new DataSourcePermissionClientWrapper(editMode);

// Add data source permission client wrapper factory
core.savedObjects.addClientWrapper(
2,
yubonluo marked this conversation as resolved.
Show resolved Hide resolved
DATA_SOURCE_PERMISSION_CLIENT_WRAPPER_ID,
dataSourcePermissionWrapper.wrapperFactory
);
}

const dataSourceSavedObjectsClientWrapper = new DataSourceSavedObjectsClientWrapper(
cryptographyServiceSetup,
this.logger.get('data-source-saved-objects-client-wrapper-factory'),
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import * as utils from '../../../../core/server/utils';
import { coreMock, httpServerMock, savedObjectsClientMock } from '../../../../core/server/mocks';
import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../common';
import { DataSourcePermissionClientWrapper } from './data_source_permission_client_wrapper';
import { EditMode } from '../../common/data_sources';

jest.mock('../../../../core/server/utils');

describe('DataSourcePermissionClientWrapper', () => {
const requestHandlerContext = coreMock.createRequestHandlerContext();
const requestMock = httpServerMock.createOpenSearchDashboardsRequest();

const attributes = (attribute?: any) => {
return {
title: 'data-source',
description: 'jest testing',
endpoint: 'https://test.com',
...attribute,
};
};

const dataSource = {
type: DATA_SOURCE_SAVED_OBJECT_TYPE,
attributes,
};
const dashboard = {
type: 'dashboard',
attributes: {},
};

const errorMessage = 'You have no permission to perform this operation';

describe('edit mode is admin_only', () => {
describe('user is not osd admin', () => {
const mockedClient = savedObjectsClientMock.create();
const wrapperInstance = new DataSourcePermissionClientWrapper(EditMode.AdminOnly);
const wrapperClient = wrapperInstance.wrapperFactory({
client: mockedClient,
typeRegistry: requestHandlerContext.savedObjects.typeRegistry,
request: requestMock,
});

it('should not create data source when user is not admin', async () => {
let errorCatched;
try {
await wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {});
} catch (e) {
errorCatched = e;
}
expect(errorCatched.message).toEqual(errorMessage);
});

it('should not bulk create data source when user is not admin', async () => {
const mockCreateObjects = [dataSource, dashboard];
const result = await wrapperClient.bulkCreate(mockCreateObjects);
expect(result.saved_objects[0].error?.message).toEqual(errorMessage);
});

it('should not update data source when user is not admin', async () => {
let errorCatched;
try {
await wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', {});
} catch (e) {
errorCatched = e;
}
expect(errorCatched.message).toEqual(errorMessage);
});

it('should not bulk update data source when user is not admin', async () => {
const mockCreateObjects = [
{ ...dataSource, id: 'data-source-id' },
{ ...dashboard, id: 'dashboard-id' },
];
const result = await wrapperClient.bulkUpdate(mockCreateObjects);
expect(result.saved_objects[0].error?.message).toEqual(errorMessage);
});

it('should not delete data source when user is not admin', async () => {
let errorCatched;
try {
await wrapperClient.delete(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id');
} catch (e) {
errorCatched = e;
}
expect(errorCatched.message).toEqual(errorMessage);
});
});
describe('user is osd admin', () => {
jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDashboardAdmin: true });
const mockedClient = savedObjectsClientMock.create();
const wrapperInstance = new DataSourcePermissionClientWrapper(EditMode.AdminOnly);
const wrapperClient = wrapperInstance.wrapperFactory({
client: mockedClient,
typeRegistry: requestHandlerContext.savedObjects.typeRegistry,
request: requestMock,
});

it('should create data source when user is admin', async () => {
jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDashboardAdmin: true });
await wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {});
expect(mockedClient.create).toBeCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {});
});

it('should bulk create data source when user is admin', async () => {
jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDashboardAdmin: true });
const mockCreateObjects = [dataSource];
await wrapperClient.bulkCreate(mockCreateObjects, { overwrite: true });
expect(mockedClient.bulkCreate).toBeCalledWith(mockCreateObjects, { overwrite: true });
});

it('should update data source when user is admin', async () => {
jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDashboardAdmin: true });
await wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', {});
expect(mockedClient.update).toBeCalledWith(
DATA_SOURCE_SAVED_OBJECT_TYPE,
'data-source-id',
{}
);
});

it('should bulk update data source when user is admin', async () => {
jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDashboardAdmin: true });
const mockUpdateObjects = [
{
...dataSource,
id: 'data-source-id',
},
];
await wrapperClient.bulkUpdate(mockUpdateObjects, {});
expect(mockedClient.bulkUpdate).toBeCalledWith(mockUpdateObjects, {});
});

it('should delete data source', async () => {
jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDashboardAdmin: true });
await wrapperClient.delete(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id');
expect(mockedClient.delete).toBeCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id');
});
});
});

describe('edit mode is read_only', () => {
const mockedClient = savedObjectsClientMock.create();
const wrapperInstance = new DataSourcePermissionClientWrapper(EditMode.ReadOnly);
const wrapperClient = wrapperInstance.wrapperFactory({
client: mockedClient,
typeRegistry: requestHandlerContext.savedObjects.typeRegistry,
request: requestMock,
});

it('should not create data source', async () => {
let errorCatched;
try {
await wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {});
} catch (e) {
errorCatched = e;
}
expect(errorCatched.message).toEqual(errorMessage);

await wrapperClient.create('dashboard', {}, {});
expect(mockedClient.create).toBeCalledWith('dashboard', {}, {});
});

it('should not bulk create data source', async () => {
const mockCreateObjects = [dataSource, dashboard];
const result = await wrapperClient.bulkCreate(mockCreateObjects);
expect(result.saved_objects[0].error?.message).toEqual(errorMessage);
});

it('should not update data source', async () => {
let errorCatched;
try {
await wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', {});
} catch (e) {
errorCatched = e;
}
expect(errorCatched.message).toEqual(errorMessage);

await wrapperClient.update('dashboard', 'dashboard-id', {});
expect(mockedClient.update).toBeCalledWith('dashboard', 'dashboard-id', {}, {});
});

it('should not bulk update data source', async () => {
const mockCreateObjects = [
{ ...dataSource, id: 'data-source-id' },
{ ...dashboard, id: 'dashboard-id' },
];
const result = await wrapperClient.bulkUpdate(mockCreateObjects);
expect(result.saved_objects[0].error?.message).toEqual(errorMessage);
});

it('should not delete data source', async () => {
let errorCatched;
try {
await wrapperClient.delete(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id');
} catch (e) {
errorCatched = e;
}
expect(errorCatched.message).toEqual(errorMessage);

await wrapperClient.delete('dashboard', 'dashboard-id');
expect(mockedClient.delete).toBeCalledWith('dashboard', 'dashboard-id', {});
});
});
});
Loading