-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Integrate dynamic config to disable global settings updates for non-admin users #10959
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| feat: | ||
| - Integrate dynamic config to disable global settings updates for non-admin users ([#10959](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10959)) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| /* | ||
| * Copyright OpenSearch Contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { coreMock, httpServerMock, savedObjectsClientMock } from '../../../server/mocks'; | ||
| import { DynamicConfigControlledUiSettingsWrapper } from './dynamic_config_controlled_ui_settings_wrapper'; | ||
| import { SavedObjectsErrorHelpers } from '../../../server'; | ||
| import { dynamicConfigServiceMock } from '../../config/dynamic_config_service.mock'; | ||
|
|
||
| jest.mock('opensearch-dashboards/server/utils', () => ({ | ||
| getWorkspaceState: jest.fn().mockImplementation((request) => ({ | ||
| isDashboardAdmin: request.isDashboardAdmin, | ||
| })), | ||
| pkg: { | ||
| build: { | ||
| distributable: true, | ||
| release: true, | ||
| }, | ||
| }, | ||
| })); | ||
|
|
||
| describe('DynamicConfigControlledUiSettingsWrapper', () => { | ||
| const requestHandlerContext = coreMock.createRequestHandlerContext(); | ||
| const mockedClient = savedObjectsClientMock.create(); | ||
| const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); | ||
| const dynamicConfigService = dynamicConfigServiceMock.createInternalSetupContract(); | ||
|
|
||
| // Helper to build wrapper instance | ||
| const buildWrapperInstance = (isDashboardAdmin = true, globalScopeEditable = true) => { | ||
| const getConfigMock = jest.fn().mockResolvedValue({ | ||
| globalScopeEditable: { | ||
| enabled: globalScopeEditable, | ||
| }, | ||
| }); | ||
| jest.spyOn(dynamicConfigService, 'getStartService').mockResolvedValue({ | ||
| ...dynamicConfigService.getStartService(), | ||
| getAsyncLocalStore: jest.fn(), | ||
| getClient: () => ({ | ||
| getConfig: getConfigMock, | ||
| bulkGetConfigs: jest.fn(), | ||
| listConfigs: jest.fn(), | ||
| }), | ||
| }); | ||
|
|
||
| const wrapperInstance = new DynamicConfigControlledUiSettingsWrapper(dynamicConfigService); | ||
|
|
||
| // Set isDashboardAdmin property on request | ||
| (requestMock as any).isDashboardAdmin = isDashboardAdmin; | ||
|
|
||
| const wrapperClient = wrapperInstance.wrapperFactory({ | ||
| client: mockedClient, | ||
| typeRegistry: requestHandlerContext.savedObjects.typeRegistry, | ||
| request: requestMock, | ||
| }); | ||
| return wrapperClient; | ||
| }; | ||
|
|
||
| describe('#create', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('should pass through non-config type requests', async () => { | ||
| const wrapperClient = buildWrapperInstance(); | ||
| await wrapperClient.get('dashboard', 'test-id'); | ||
| expect(mockedClient.get).toBeCalledWith('dashboard', 'test-id'); | ||
| }); | ||
|
|
||
| it('should handle regular config requests', async () => { | ||
| const wrapperClient = buildWrapperInstance(); | ||
| const mockResponse = { | ||
| id: '3.0.0', | ||
| type: 'config', | ||
| attributes: { 'csv:quoteValues': 'true' }, | ||
| references: [], | ||
| }; | ||
| mockedClient.get.mockResolvedValue(mockResponse); | ||
|
|
||
| const result = await wrapperClient.get('config', '3.0.0'); | ||
|
|
||
| expect(mockedClient.get).toBeCalledWith('config', '3.0.0'); | ||
| expect(result).toEqual(mockResponse); | ||
| }); | ||
| }); | ||
|
|
||
| describe('#update', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('should pass through non-config type requests', async () => { | ||
| const wrapperClient = buildWrapperInstance(); | ||
| const attributes = { 'csv:quoteValues': 'true' }; | ||
| await wrapperClient.update('dashboard', 'test-id', attributes); | ||
| expect(mockedClient.update).toBeCalledWith('dashboard', 'test-id', attributes, {}); | ||
| }); | ||
|
|
||
| it('should pass through regular config requests when globalScopeEditable is enabled', async () => { | ||
| const wrapperClient = buildWrapperInstance(); | ||
| const attributes = { 'csv:quoteValues': 'true' }; | ||
| await wrapperClient.update('config', '3.0.0', attributes); | ||
| expect(mockedClient.update).toBeCalledWith('config', '3.0.0', attributes, {}); | ||
| }); | ||
| it('should only consider global ui settings updates', async () => { | ||
| const wrapperClient = buildWrapperInstance(false, false); | ||
| const attributes = { 'csv:quoteValues': 'true' }; | ||
| await wrapperClient.update('config', '<current_user>_3.0.0', attributes); | ||
| expect(mockedClient.update).toBeCalledWith('config', '<current_user>_3.0.0', attributes, {}); | ||
| }); | ||
|
|
||
| it('should update global settings when user is dashboard admin even globalScopeEditable is disabled', async () => { | ||
| const wrapperClient = buildWrapperInstance(true, false); | ||
| const attributes = { 'csv:quoteValues': 'true' }; | ||
| const mockResponse = { | ||
| id: '3.0.0', | ||
| type: 'config', | ||
| attributes, | ||
| references: [], | ||
| }; | ||
|
|
||
| mockedClient.update.mockResolvedValue(mockResponse); | ||
|
|
||
| await wrapperClient.update('config', '3.0.0', attributes); | ||
|
|
||
| expect(mockedClient.update).toBeCalledWith('config', '3.0.0', attributes, {}); | ||
| }); | ||
|
|
||
| it('should throw error when user is not dashboard admin and globalScopeEditable is disabled', async () => { | ||
| const wrapperClient = buildWrapperInstance(false, false); | ||
| const attributes = { permissionControlledSetting: true }; | ||
|
|
||
| mockedClient.update.mockImplementation(() => { | ||
| throw SavedObjectsErrorHelpers.createGenericNotFoundError('config', '3.0.0'); | ||
| }); | ||
|
|
||
| await expect(wrapperClient.update('config', '3.0.0', attributes)).rejects.toThrow( | ||
| 'No permission for UI settings operations' | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('#bulkUpdate', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('should pass through bulk update requests', async () => { | ||
| const attributes = { 'csv:quoteValues': 'true' }; | ||
| const wrapperClient = buildWrapperInstance(); | ||
| const objects = [ | ||
| { | ||
| type: 'config', | ||
| id: '3.0.0', | ||
| attributes, | ||
| }, | ||
| ]; | ||
|
|
||
| await wrapperClient.bulkUpdate(objects); | ||
|
|
||
| expect(mockedClient.bulkUpdate).toBeCalledWith(objects); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| /* | ||
| * Copyright OpenSearch Contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { i18n } from '@osd/i18n'; | ||
| import { | ||
| SavedObjectsClientWrapperFactory, | ||
| SavedObjectsClientWrapperOptions, | ||
| SavedObjectsErrorHelpers, | ||
| SavedObjectsUpdateOptions, | ||
| SavedObjectsUpdateResponse, | ||
| } from '../../../server'; | ||
| import { getWorkspaceState } from '../../../server/utils'; | ||
| import { isGlobalScope } from '../utils'; | ||
| import { InternalDynamicConfigServiceSetup } from '../../config'; | ||
|
|
||
| /** | ||
| * Wrapper for reading dynamic feature flag to decide if global UI settings | ||
| * are editable or not for non-admin users | ||
| * @param dynamicConfig | ||
| */ | ||
| export class DynamicConfigControlledUiSettingsWrapper { | ||
| constructor(private readonly dynamicConfig: InternalDynamicConfigServiceSetup) {} | ||
|
|
||
| public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { | ||
| const updateUiSettingsWithPermission = async <T = unknown>( | ||
| type: string, | ||
| id: string, | ||
| attributes: Partial<T>, | ||
| options: SavedObjectsUpdateOptions = {} | ||
| ): Promise<SavedObjectsUpdateResponse<T>> => { | ||
| if (type === 'config' && isGlobalScope(id)) { | ||
| const hasPermission = await this.checkPermission(wrapperOptions); | ||
| if (!hasPermission) throw this.generatePermissionError(); | ||
| } | ||
|
|
||
| return wrapperOptions.client.update<T>(type, id, attributes, options); | ||
| }; | ||
|
|
||
| return { | ||
| ...wrapperOptions.client, | ||
| create: wrapperOptions.client.create, | ||
| bulkCreate: wrapperOptions.client.bulkCreate, | ||
| delete: wrapperOptions.client.delete, | ||
| update: updateUiSettingsWithPermission, | ||
| bulkUpdate: wrapperOptions.client.bulkUpdate, | ||
| get: wrapperOptions.client.get, | ||
| checkConflicts: wrapperOptions.client.checkConflicts, | ||
| errors: wrapperOptions.client.errors, | ||
| addToNamespaces: wrapperOptions.client.addToNamespaces, | ||
| deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, | ||
| find: wrapperOptions.client.find, | ||
| bulkGet: wrapperOptions.client.bulkGet, | ||
| deleteByWorkspace: wrapperOptions.client.deleteByWorkspace, | ||
| }; | ||
| }; | ||
|
|
||
| private async checkPermission( | ||
| wrapperOptions: SavedObjectsClientWrapperOptions | ||
| ): Promise<boolean> { | ||
| // If saved object permission is disabled, getWorkspaceState will return undefined,everyone should be treated as admin here | ||
| if (getWorkspaceState(wrapperOptions.request).isDashboardAdmin !== false) return true; | ||
|
|
||
| try { | ||
| const dynamicConfigServiceStart = await this.dynamicConfig.getStartService(); | ||
| const store = dynamicConfigServiceStart.getAsyncLocalStore(); | ||
| const client = dynamicConfigServiceStart.getClient(); | ||
|
|
||
| const dynamicConfig = await client.getConfig( | ||
Qxisylolo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { pluginConfigPath: 'uiSettings' }, | ||
| { asyncLocalStorageContext: store! } | ||
| ); | ||
|
|
||
| // 1. when globalScopeEditable is false, only dashboard admin can edit global settings | ||
| // 2. when globalScopeEditable is true(default), both dashboard admin and non-admin users can edit global settings | ||
| return dynamicConfig.globalScopeEditable.enabled; | ||
| } catch (e) { | ||
| throw new Error( | ||
| i18n.translate('core.dynamic.config.controlled.ui.settings.read.invalidate', { | ||
| defaultMessage: 'Unable to read dynamic config', | ||
| }) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| private generatePermissionError = () => | ||
| SavedObjectsErrorHelpers.decorateForbiddenError( | ||
| new Error( | ||
| i18n.translate('core.dynamic.config.controlled.ui.settings.permission.invalidate', { | ||
| defaultMessage: 'No permission for UI settings operations', | ||
| }) | ||
| ) | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,6 +15,9 @@ export const PERMISSION_CONTROLLED_UI_SETTINGS_WRAPPER_ID = 'permission-control- | |||||||||
| // other wrappers, therefore the priorty can be any number and the priority of 100 is trival | ||||||||||
| export const PERMISSION_CONTROLLED_UI_SETTINGS_WRAPPER_PRIORITY = 100; | ||||||||||
|
|
||||||||||
| export const DYNAMIC_CONFIG_CONTROLLEDUI_SETTINGS_WRAPPER_ID = 'dynamic-config-control-ui-settings'; | ||||||||||
| export const DYNAMIC_CONFIG_CONTROLLEDUI_SETTINGS_WRAPPER_PRIORITY = 101; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not related to this PR, but we think we can have some documentations on the orders of all the wrappers 🙂
Comment on lines
+18
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Fix naming convention: missing underscore in constant name. The constant name Apply this diff to fix the naming: -export const DYNAMIC_CONFIG_CONTROLLEDUI_SETTINGS_WRAPPER_ID = 'dynamic-config-control-ui-settings';
-export const DYNAMIC_CONFIG_CONTROLLEDUI_SETTINGS_WRAPPER_PRIORITY = 101;
+export const DYNAMIC_CONFIG_CONTROLLED_UI_SETTINGS_WRAPPER_ID = 'dynamic-config-control-ui-settings';
+export const DYNAMIC_CONFIG_CONTROLLED_UI_SETTINGS_WRAPPER_PRIORITY = 101;Note: This change requires updating all references in:
📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| export const buildDocIdWithScope = (id: string, scope?: UiSettingScope) => { | ||||||||||
| if (scope === UiSettingScope.USER) { | ||||||||||
| return `${CURRENT_USER_PLACEHOLDER}_${id}`; | ||||||||||
|
|
@@ -27,3 +30,11 @@ export const buildDocIdWithScope = (id: string, scope?: UiSettingScope) => { | |||||||||
| } | ||||||||||
| return id; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| export const isGlobalScope = (docId: string | undefined) => { | ||||||||||
| return ( | ||||||||||
| docId !== DASHBOARD_ADMIN_SETTINGS_ID && | ||||||||||
| !docId?.startsWith(CURRENT_WORKSPACE_PLACEHOLDER) && | ||||||||||
| !docId?.startsWith(CURRENT_USER_PLACEHOLDER) | ||||||||||
| ); | ||||||||||
| }; | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bulkUpdatebypasses permission checks for global config updates.The
updatemethod enforces permission checks for global-scope config, butbulkUpdatepasses through directly. A caller could potentially circumvent restrictions by usingbulkUpdatewith global config objects.Consider wrapping
bulkUpdatewith similar permission logic:🤖 Prompt for AI Agents