Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/10959.yml
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))
1 change: 1 addition & 0 deletions src/core/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export class Server {
const uiSettingsSetup = await this.uiSettings.setup({
http: httpSetup,
savedObjects: savedObjectsSetup,
dynamicConfig: dynamicConfigServiceSetup,
});
const workspaceSetup = await this.workspace.setup();

Expand Down
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,
Comment on lines +46 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

bulkUpdate bypasses permission checks for global config updates.

The update method enforces permission checks for global-scope config, but bulkUpdate passes through directly. A caller could potentially circumvent restrictions by using bulkUpdate with global config objects.

Consider wrapping bulkUpdate with similar permission logic:

+    const bulkUpdateUiSettingsWithPermission = async <T = unknown>(
+      objects: Array<{ type: string; id: string; attributes: Partial<T>; options?: SavedObjectsUpdateOptions }>
+    ) => {
+      for (const obj of objects) {
+        if (obj.type === 'config' && isGlobalScope(obj.id)) {
+          const hasPermission = await this.checkPermission(wrapperOptions);
+          if (!hasPermission) throw this.generatePermissionError();
+        }
+      }
+      return wrapperOptions.client.bulkUpdate(objects);
+    };
+
     return {
       ...wrapperOptions.client,
       create: wrapperOptions.client.create,
       bulkCreate: wrapperOptions.client.bulkCreate,
       delete: wrapperOptions.client.delete,
       update: updateUiSettingsWithPermission,
-      bulkUpdate: wrapperOptions.client.bulkUpdate,
+      bulkUpdate: bulkUpdateUiSettingsWithPermission,

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/core/server/ui_settings/saved_objects/dynamic_config_controlled_ui_settings_wrapper.ts
around lines 46 to 47, bulkUpdate is forwarded directly to
wrapperOptions.client.bulkUpdate which bypasses the permission checks applied to
updateUiSettingsWithPermission; implement a wrapper for bulkUpdate that mirrors
the update permission logic by iterating incoming entries, enforcing the same
global-scope permission checks (rejecting or filtering entries the caller isn’t
allowed to modify) and then delegating allowed entries to the underlying
client.bulkUpdate, ensuring the method returns the same shape as the original
client while preserving error handling and logging behavior.

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(
{ 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',
})
)
);
}
3 changes: 3 additions & 0 deletions src/core/server/ui_settings/ui_settings_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ const configSchema = schema.object({
})
),
}),
globalScopeEditable: schema.object({
enabled: schema.boolean({ defaultValue: true }),
}),
});

export type UiSettingsConfigType = TypeOf<typeof configSchema>;
Expand Down
10 changes: 8 additions & 2 deletions src/core/server/ui_settings/ui_settings_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { savedObjectsClientMock } from '../mocks';
import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock';
import { mockCoreContext } from '../core_context.mock';
import { uiSettingsType } from './saved_objects';
import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock';

const overrides = {
overrideBaz: 'baz',
Expand Down Expand Up @@ -82,7 +83,12 @@ describe('uiSettings', () => {
);
const httpSetup = httpServiceMock.createInternalSetupContract();
const savedObjectsSetup = savedObjectsServiceMock.createInternalSetupContract();
setupDeps = { http: httpSetup, savedObjects: savedObjectsSetup };
const dynamicConfigService = dynamicConfigServiceMock.createInternalSetupContract();
setupDeps = {
http: httpSetup,
savedObjects: savedObjectsSetup,
dynamicConfig: dynamicConfigService,
};
savedObjectsClient = savedObjectsClientMock.create();
service = new UiSettingsService(coreContext);
jest.spyOn(service as any, 'register');
Expand All @@ -103,7 +109,7 @@ describe('uiSettings', () => {
it('register adminUiSettings', async () => {
const setup = await service.setup(setupDeps);
setup.register(adminUiSettings);
expect(setupDeps.savedObjects.addClientWrapper).toHaveBeenCalledTimes(1);
expect(setupDeps.savedObjects.addClientWrapper).toHaveBeenCalledTimes(2);

expect((service as any).register).toHaveBeenCalledWith(adminUiSettings);
});
Expand Down
22 changes: 20 additions & 2 deletions src/core/server/ui_settings/ui_settings_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
import { combineLatest, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { firstValueFrom, mapToObject } from '@osd/std';

import { CoreService } from '../../types';
import { CoreContext } from '../core_context';
import { Logger } from '../logging';
Expand All @@ -56,12 +55,17 @@ import {
import {
PERMISSION_CONTROLLED_UI_SETTINGS_WRAPPER_ID,
PERMISSION_CONTROLLED_UI_SETTINGS_WRAPPER_PRIORITY,
DYNAMIC_CONFIG_CONTROLLEDUI_SETTINGS_WRAPPER_ID,
DYNAMIC_CONFIG_CONTROLLEDUI_SETTINGS_WRAPPER_PRIORITY,
} from './utils';
import { getAIFeaturesSetting } from './settings/ai_features';
import { InternalDynamicConfigServiceSetup } from '../config';
import { DynamicConfigControlledUiSettingsWrapper } from './saved_objects/dynamic_config_controlled_ui_settings_wrapper';

export interface SetupDeps {
http: InternalHttpServiceSetup;
savedObjects: InternalSavedObjectsServiceSetup;
dynamicConfig: InternalDynamicConfigServiceSetup;
}

/** @internal */
Expand All @@ -81,7 +85,11 @@ export class UiSettingsService
]);
}

public async setup({ http, savedObjects }: SetupDeps): Promise<InternalUiSettingsServiceSetup> {
public async setup({
http,
savedObjects,
dynamicConfig,
}: SetupDeps): Promise<InternalUiSettingsServiceSetup> {
this.log.debug('Setting up ui settings service');

savedObjects.registerType(uiSettingsType);
Expand All @@ -105,12 +113,22 @@ export class UiSettingsService
config.savedObjectsConfig.permission.enabled
);

const dynamicConfigControlledUiSettingsWrapper = new DynamicConfigControlledUiSettingsWrapper(
dynamicConfig
);

savedObjects.addClientWrapper(
PERMISSION_CONTROLLED_UI_SETTINGS_WRAPPER_PRIORITY,
PERMISSION_CONTROLLED_UI_SETTINGS_WRAPPER_ID,
permissionControlledUiSettingsWrapper.wrapperFactory
);

savedObjects.addClientWrapper(
DYNAMIC_CONFIG_CONTROLLEDUI_SETTINGS_WRAPPER_PRIORITY,
DYNAMIC_CONFIG_CONTROLLEDUI_SETTINGS_WRAPPER_ID,
dynamicConfigControlledUiSettingsWrapper.wrapperFactory
);

this.register(getAIFeaturesSetting());

return {
Expand Down
11 changes: 11 additions & 0 deletions src/core/server/ui_settings/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link

Choose a reason for hiding this comment

The 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 DYNAMIC_CONFIG_CONTROLLEDUI_SETTINGS_WRAPPER_ID is missing an underscore before UI. This breaks the naming convention used by similar constants.

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:

  • src/core/server/ui_settings/ui_settings_service.ts (lines 58, 128)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
🤖 Prompt for AI Agents
In src/core/server/ui_settings/utils.ts around lines 18 to 19, the constant
DYNAMIC_CONFIG_CONTROLLEDUI_SETTINGS_WRAPPER_ID is missing an underscore before
"UI" and should be renamed to DYNAMIC_CONFIG_CONTROLLED_UI_SETTINGS_WRAPPER_ID;
update the constant name in this file and incrementally update all references
(notably in src/core/server/ui_settings/ui_settings_service.ts at lines 58 and
128) to the new identifier so imports/usages remain consistent.


export const buildDocIdWithScope = (id: string, scope?: UiSettingScope) => {
if (scope === UiSettingScope.USER) {
return `${CURRENT_USER_PLACEHOLDER}_${id}`;
Expand All @@ -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)
);
};
Loading
Loading