diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da65b8ae2e..2457b2a1cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Theme] Use themes' definitions to render the initial view ([#4936](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4936)) - [Theme] Make `next` theme the default ([#4854](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4854)) - [Discover] Update embeddable for saved searches ([#5081](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5081)) +- Add support for read-only mode through tenants ([#4498](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4498)) - [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)) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index e8ce1bfae55..748210537e6 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -258,6 +258,12 @@ Options: $ yarn opensearch snapshot --version 2.2.0 -E cluster.name=test -E path.data=/tmp/opensearch-data --P org.opensearch.plugin:test-plugin:2.2.0.0 --P file:/home/user/opensearch-test-plugin-2.2.0.0.zip ``` +#### Read Only capabilities + +_This feature will only work if you have the [`security` plugin](https://github.com/opensearch-project/security) installed on your OpenSearch cluster with https/authentication enabled._ + +Please follow the design described in [the docs](https://github.com/opensearch-project/OpenSearch/blob/main/docs/capabilities/read_only_mode.md#design) + ### Alternative - Run OpenSearch from tarball If you would like to run OpenSearch from the tarball, you'll need to download the minimal distribution, install it, and then run the executable. (You'll also need Java installed and the `JAVA_HOME` environmental variable set - see [OpenSearch developer guide](https://github.com/opensearch-project/OpenSearch/blob/main/DEVELOPER_GUIDE.md#install-prerequisites) for details). diff --git a/docs/capabilities/read_only_mode.md b/docs/capabilities/read_only_mode.md new file mode 100644 index 00000000000..a1e14d119cb --- /dev/null +++ b/docs/capabilities/read_only_mode.md @@ -0,0 +1,80 @@ +# Read-only Mode + +There are two distinct functionalities for "read-only" access in Dashboards. One of them is associated with roles and one is associated with tenants. Regarding the first one, the Dashboards Security plugin contains a feature of hiding all plugin navigation links except Dashboards and Visualizations when the logged-in user has a certain role (more about it in [Read-only Role](#read-only-role)). + +The second one is limiting Dashboards access rights via assigning a specific role to a tenant (therefore, making a tenant read-only). Due to past issues and the deprecation of the first functionality, using read-only tenants is now the recommended way to limit users' access to Dashboards. + +## Design + +Whenever a plugin registers capabilities that should be limited (in other words, set to false) for read-only tenants, such capabilities should be registered through `registerSwitcher` with using method `core.security.readonlyService().hideForReadonly()` + +### Example + +```ts +public setup(core: CoreSetup) { + core.capabilities.registerProvider({ + myAwesomePlugin: { + show: true, + save: true, + delete: true, + } + }); + + core.capabilities.registerSwitcher(async (request, capabilites) => { + return await core.security.readonlyService().hideForReadonly(request, capabilites, { + myAwesomePlugin: { + save: false, + delete: false, + }, + }); + }); +} +``` + +In this case, we might assume that a plugin relies on the `save` and `delete` capabilities to limit changes somewhere in the UI. Therefore, those capabilities are processed through `registerSwitcher`, they will be set to `false` whenever a read-only tenant is accessed. + +If `registerSwitcher` will try to provide or remove capabilites when invoking the switcher will be ignored. + +*In case of a disabled / not installed `security` plugin changes will be never applied to a capabilites.* + +## Requirements + +This feature will only work if you have the [`security` plugin](https://github.com/opensearch-project/security) installed on your OpenSearch cluster with https/authentication enabled. + +## Read-only Role + +The role is called `kibana_read_only` by default, but the name can be changed using the dashboard config option `opensearch_security.readonly_mode.roles`. One big issue with this feature is that the backend site of a Dashboard Security plugin is completely unaware of it. Thus, users in this mode still have write access to the Dashboards saved objects via the API as the implementation effectively hides everything except the Dashboards and Visualization plugins. + +**We highly do not recommend using it!** + +For more context, see [this group issues of problems connected with read-only roles](https://github.com/opensearch-project/security/issues/2701). + +### Usage + +1. Go to `Management > Security > Internal users` +2. Create or select an already existing user +3. Add a new `Backend role` called `kibana_read_only` (or use name used in `opensearch_security.readonly_mode.roles`) +4. Save changes + +## Read-only Tenant (recommended) + +Dashboards Security plugin recognizes the selection of read-only tenant after logging in and sets the capabilities associated with write access or showing write controls to false for a variety of plugins. This can be easily checked for example by trying to re-arrange some visualizations on Dashboards. Such action will be resulting in a 403 error due to limited read-only access. + +### Usage + +1. Prepare tenant: + * Use an existing tenant or create a new one in `Management > Security > Tenants` +2. Prepare role: + * Go to `Management > Security > Roles` + * Use an existing role or create a new one + * Fill **index permissions** with: + * `indices:data/read/search` + * `indices:data/read/get` + * Add new **tenant permission** with: + * your name of the tenant + * read only +3. Assign a role to a user: + * Go to role + * Click the tab `Mapped users` + * Click `Manage mapping` + * In `Users` select the user that will be affected diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index f6efbfac422..62c13694e24 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -32,6 +32,7 @@ import React from 'react'; import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { map, shareReplay, takeUntil, distinctUntilChanged, filter } from 'rxjs/operators'; import { createBrowserHistory, History } from 'history'; +import { RecursiveReadonly } from '@osd/utility-types'; import { MountPoint } from '../types'; import { HttpSetup, HttpStart } from '../http'; @@ -73,7 +74,7 @@ interface StartDeps { // Mount functions with two arguments are assumed to expect deprecated `context` object. const isAppMountDeprecated = (mount: (...args: any[]) => any): mount is AppMountDeprecated => mount.length === 2; -function filterAvailable(m: Map, capabilities: Capabilities) { +function filterAvailable(m: Map, capabilities: RecursiveReadonly) { return new Map( [...m].filter( ([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 379411398fc..cbd4921680d 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -76,6 +76,7 @@ import { StatusServiceSetup } from './status'; import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail'; import { AppenderConfigType, appendersSchema, LoggingServiceSetup } from './logging'; import { CoreUsageDataStart } from './core_usage_data'; +import { SecurityServiceSetup } from './security/types'; // Because of #79265 we need to explicity import, then export these types for // scripts/telemetry_check.js to work as expected @@ -437,6 +438,8 @@ export interface CoreSetup = OsdServer as any; @@ -108,6 +109,7 @@ beforeEach(() => { auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createInternalSetupContract(), metrics: metricsServiceMock.createInternalSetupContract(), + security: securityServiceMock.createSetupContract(), }, plugins: { 'plugin-id': 'plugin-value' }, uiPlugins: { diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 165a67aa1f8..7974b6fa304 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -301,6 +301,7 @@ export class LegacyService implements CoreService { }, auditTrail: setupDeps.core.auditTrail, getStartServices: () => Promise.resolve([coreStart, startDeps.plugins, {}]), + security: setupDeps.core.security, }; // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 3dd289669a0..c253e95245d 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -50,6 +50,7 @@ import { environmentServiceMock } from './environment/environment_service.mock'; import { statusServiceMock } from './status/status_service.mock'; import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock'; import { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; +import { securityServiceMock } from './security/security_service.mock'; export { configServiceMock } from './config/mocks'; export { httpServerMock } from './http/http_server.mocks'; @@ -157,6 +158,7 @@ function createCoreSetupMock({ getStartServices: jest .fn, object, any]>, []>() .mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]), + security: securityServiceMock.createSetupContract(), }; return mock; @@ -192,6 +194,7 @@ function createInternalCoreSetupMock() { auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createInternalSetupContract(), metrics: metricsServiceMock.createInternalSetupContract(), + security: securityServiceMock.createSetupContract(), }; return setupDeps; } diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index ab028e169a7..39e9bef7e4f 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -220,6 +220,7 @@ export function createPluginSetupContext( }, getStartServices: () => plugin.startDependencies, auditTrail: deps.auditTrail, + security: deps.security, }; } diff --git a/src/core/server/security/readonly_service.test.ts b/src/core/server/security/readonly_service.test.ts new file mode 100644 index 00000000000..739d9e3daac --- /dev/null +++ b/src/core/server/security/readonly_service.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenSearchDashboardsRequest } from '../index'; +import { ReadonlyService } from './readonly_service'; +import { httpServerMock } from '../http/http_server.mocks'; + +describe('ReadonlyService', () => { + let readonlyService: ReadonlyService; + let request: OpenSearchDashboardsRequest; + + beforeEach(() => { + readonlyService = new ReadonlyService(); + request = httpServerMock.createOpenSearchDashboardsRequest(); + }); + + it('isReadonly returns false by default', () => { + expect(readonlyService.isReadonly(request)).resolves.toBeFalsy(); + }); + + it('hideForReadonly merges capabilites to hide', () => { + readonlyService.isReadonly = jest.fn(() => new Promise(() => true)); + const result = readonlyService.hideForReadonly( + request, + { foo: { show: true } }, + { foo: { show: false } } + ); + + expect(readonlyService.isReadonly).toBeCalledTimes(1); + expect(result).resolves.toEqual({ foo: { show: false } }); + }); +}); diff --git a/src/core/server/security/readonly_service.ts b/src/core/server/security/readonly_service.ts new file mode 100644 index 00000000000..a41dc0fde3b --- /dev/null +++ b/src/core/server/security/readonly_service.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { merge } from 'lodash'; +import { OpenSearchDashboardsRequest, Capabilities } from '../index'; +import { IReadOnlyService } from './types'; + +export class ReadonlyService implements IReadOnlyService { + async isReadonly(request: OpenSearchDashboardsRequest): Promise { + return false; + } + + async hideForReadonly( + request: OpenSearchDashboardsRequest, + capabilites: Partial, + hideCapabilities: Partial + ): Promise> { + return (await this.isReadonly(request)) ? merge(capabilites, hideCapabilities) : capabilites; + } +} diff --git a/src/core/server/security/security_service.mock.ts b/src/core/server/security/security_service.mock.ts new file mode 100644 index 00000000000..687509e42b5 --- /dev/null +++ b/src/core/server/security/security_service.mock.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SecurityServiceSetup } from './types'; + +const createSetupContractMock = () => { + const setupContract: jest.Mocked = { + readonlyService: jest.fn(), + registerReadonlyService: jest.fn(), + }; + return setupContract; +}; + +export const securityServiceMock = { + createSetupContract: createSetupContractMock, +}; diff --git a/src/core/server/security/security_service.test.ts b/src/core/server/security/security_service.test.ts new file mode 100644 index 00000000000..cf09b64ae36 --- /dev/null +++ b/src/core/server/security/security_service.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenSearchDashboardsRequest } from '../index'; +import { mockCoreContext } from '../core_context.mock'; +import { SecurityService } from './security_service'; +import { httpServerMock } from '../http/http_server.mocks'; +import { IReadOnlyService } from './types'; + +describe('SecurityService', () => { + let securityService: SecurityService; + let request: OpenSearchDashboardsRequest; + + beforeEach(() => { + const coreContext = mockCoreContext.create(); + securityService = new SecurityService(coreContext); + request = httpServerMock.createOpenSearchDashboardsRequest(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('#readonlyService', () => { + it("uses core's readonly service by default", () => { + const setupContext = securityService.setup(); + expect(setupContext.readonlyService().isReadonly(request)).resolves.toBeFalsy(); + }); + + it('registers custom readonly service and it uses it', () => { + const setupContext = securityService.setup(); + const readonlyServiceMock: jest.Mocked = { + isReadonly: jest.fn(), + hideForReadonly: jest.fn(), + }; + + setupContext.registerReadonlyService(readonlyServiceMock); + setupContext.readonlyService().isReadonly(request); + + expect(readonlyServiceMock.isReadonly).toBeCalledTimes(1); + }); + }); +}); diff --git a/src/core/server/security/security_service.ts b/src/core/server/security/security_service.ts new file mode 100644 index 00000000000..1916afc165d --- /dev/null +++ b/src/core/server/security/security_service.ts @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreService } from '../../types'; +import { IReadOnlyService, InternalSecurityServiceSetup } from './types'; +import { CoreContext } from '../core_context'; +import { Logger } from '../logging'; +import { ReadonlyService } from './readonly_service'; + +export class SecurityService implements CoreService { + private logger: Logger; + private readonlyService: IReadOnlyService; + + constructor(coreContext: CoreContext) { + this.logger = coreContext.logger.get('security-service'); + this.readonlyService = new ReadonlyService(); + } + + public setup() { + this.logger.debug('Setting up Security service'); + + const securityService = this; + + return { + registerReadonlyService(service: IReadOnlyService) { + securityService.readonlyService = service; + }, + readonlyService() { + return securityService.readonlyService; + }, + }; + } + + public start() { + this.logger.debug('Starting plugin'); + } + + public stop() { + this.logger.debug('Stopping plugin'); + } +} diff --git a/src/core/server/security/types.ts b/src/core/server/security/types.ts new file mode 100644 index 00000000000..43a599d9962 --- /dev/null +++ b/src/core/server/security/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Capabilities, OpenSearchDashboardsRequest } from '../index'; + +export interface SecurityServiceSetup { + registerReadonlyService(service: IReadOnlyService): void; + readonlyService(): IReadOnlyService; +} + +export type InternalSecurityServiceSetup = SecurityServiceSetup; + +export interface IReadOnlyService { + isReadonly(request: OpenSearchDashboardsRequest): Promise; + hideForReadonly( + request: OpenSearchDashboardsRequest, + capabilites: Capabilities, + hideCapabilities: Partial + ): Promise>; +} diff --git a/src/core/server/server.ts b/src/core/server/server.ts index d4c041725ac..83ebbdf8938 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -47,6 +47,7 @@ import { MetricsService, opsConfig } from './metrics'; import { CapabilitiesService } from './capabilities'; import { EnvironmentService, config as pidConfig } from './environment'; import { StatusService } from './status/status_service'; +import { SecurityService } from './security/security_service'; import { config as cspConfig } from './csp'; import { config as opensearchConfig } from './opensearch'; @@ -86,6 +87,7 @@ export class Server { private readonly coreApp: CoreApp; private readonly auditTrail: AuditTrailService; private readonly coreUsageData: CoreUsageDataService; + private readonly security: SecurityService; #pluginsInitialized?: boolean; private coreStart?: InternalCoreStart; @@ -118,6 +120,7 @@ export class Server { this.auditTrail = new AuditTrailService(core); this.logging = new LoggingService(core); this.coreUsageData = new CoreUsageDataService(core); + this.security = new SecurityService(core); } public async setup() { @@ -196,6 +199,8 @@ export class Server { loggingSystem: this.loggingSystem, }); + const securitySetup = this.security.setup(); + this.coreUsageData.setup({ metrics: metricsSetup }); const coreSetup: InternalCoreSetup = { @@ -212,6 +217,7 @@ export class Server { auditTrail: auditTrailSetup, logging: loggingSetup, metrics: metricsSetup, + security: securitySetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); @@ -277,6 +283,8 @@ export class Server { await this.http.start(); + await this.security.start(); + startTransaction?.end(); return this.coreStart; } @@ -295,6 +303,7 @@ export class Server { await this.status.stop(); await this.logging.stop(); await this.auditTrail.stop(); + await this.security.stop(); } private registerCoreContext(coreSetup: InternalCoreSetup) { diff --git a/src/plugins/advanced_settings/server/plugin.ts b/src/plugins/advanced_settings/server/plugin.ts index 46ea4b3f896..094d975acf9 100644 --- a/src/plugins/advanced_settings/server/plugin.ts +++ b/src/plugins/advanced_settings/server/plugin.ts @@ -49,6 +49,14 @@ export class AdvancedSettingsServerPlugin implements Plugin { core.capabilities.registerProvider(capabilitiesProvider); + core.capabilities.registerSwitcher(async (request, capabilites) => { + return await core.security.readonlyService().hideForReadonly(request, capabilites, { + advancedSettings: { + save: false, + }, + }); + }); + return {}; } diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index 4c33bc1d606..fa89863198c 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -50,7 +50,7 @@ export class ConsoleServerPlugin implements Plugin { this.log = this.ctx.logger.get(); } - async setup({ http, capabilities, getStartServices, opensearch }: CoreSetup) { + async setup({ http, capabilities, opensearch, security }: CoreSetup) { capabilities.registerProvider(() => ({ dev_tools: { show: true, @@ -58,6 +58,14 @@ export class ConsoleServerPlugin implements Plugin { }, })); + capabilities.registerSwitcher(async (request, capabilites) => { + return await security.readonlyService().hideForReadonly(request, capabilites, { + dev_tools: { + save: false, + }, + }); + }); + const config = await this.ctx.config.create().pipe(first()).toPromise(); const globalConfig = await this.ctx.config.legacy.globalConfig$.pipe(first()).toPromise(); const proxyPathFilters = config.proxyFilter.map((str: string) => new RegExp(str)); diff --git a/src/plugins/dashboard/server/plugin.ts b/src/plugins/dashboard/server/plugin.ts index 49eb29706b0..4e377e24bbc 100644 --- a/src/plugins/dashboard/server/plugin.ts +++ b/src/plugins/dashboard/server/plugin.ts @@ -53,6 +53,15 @@ export class DashboardPlugin implements Plugin { + return await core.security.readonlyService().hideForReadonly(request, capabilites, { + dashboard: { + createNew: false, + showWriteControls: false, + saveQuery: false, + }, + }); + }); return {}; } diff --git a/src/plugins/data/server/index_patterns/index_patterns_service.ts b/src/plugins/data/server/index_patterns/index_patterns_service.ts index 4917e4240f5..29021794e88 100644 --- a/src/plugins/data/server/index_patterns/index_patterns_service.ts +++ b/src/plugins/data/server/index_patterns/index_patterns_service.ts @@ -59,6 +59,13 @@ export class IndexPatternsService implements Plugin { + return await core.security.readonlyService().hideForReadonly(request, capabilites, { + indexPatterns: { + save: false, + }, + }); + }); registerRoutes(core.http); } diff --git a/src/plugins/discover/server/plugin.ts b/src/plugins/discover/server/plugin.ts index 3c4425712d2..96b8e758fdc 100644 --- a/src/plugins/discover/server/plugin.ts +++ b/src/plugins/discover/server/plugin.ts @@ -36,6 +36,15 @@ import { searchSavedObjectType } from './saved_objects'; export class DiscoverServerPlugin implements Plugin { public setup(core: CoreSetup) { core.capabilities.registerProvider(capabilitiesProvider); + core.capabilities.registerSwitcher(async (request, capabilites) => { + return await core.security.readonlyService().hideForReadonly(request, capabilites, { + discover: { + createShortUrl: false, + save: false, + saveQuery: false, + }, + }); + }); core.uiSettings.register(uiSettings); core.savedObjects.registerType(searchSavedObjectType); diff --git a/src/plugins/saved_objects_management/server/plugin.ts b/src/plugins/saved_objects_management/server/plugin.ts index 4f9d183922a..c3053884a71 100644 --- a/src/plugins/saved_objects_management/server/plugin.ts +++ b/src/plugins/saved_objects_management/server/plugin.ts @@ -45,7 +45,7 @@ export class SavedObjectsManagementPlugin this.logger = this.context.logger.get(); } - public async setup({ http, capabilities }: CoreSetup) { + public async setup({ http, capabilities, security }: CoreSetup) { this.logger.debug('Setting up SavedObjectsManagement plugin'); registerRoutes({ http, @@ -53,6 +53,14 @@ export class SavedObjectsManagementPlugin }); capabilities.registerProvider(capabilitiesProvider); + capabilities.registerSwitcher(async (request, capabilites) => { + return await security.readonlyService().hideForReadonly(request, capabilites, { + savedObjectsManagement: { + delete: false, + edit: false, + }, + }); + }); return {}; } diff --git a/src/plugins/visualize/server/plugin.ts b/src/plugins/visualize/server/plugin.ts index aa8359d396c..086346e31ca 100644 --- a/src/plugins/visualize/server/plugin.ts +++ b/src/plugins/visualize/server/plugin.ts @@ -49,6 +49,17 @@ export class VisualizeServerPlugin implements Plugin { core.capabilities.registerProvider(capabilitiesProvider); + core.capabilities.registerSwitcher(async (request, capabilites) => { + return await core.security.readonlyService().hideForReadonly(request, capabilites, { + visualize: { + createShortUrl: false, + delete: false, + save: false, + saveQuery: false, + }, + }); + }); + return {}; }