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

Read Only Tenant Mode #4498

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b5fd84b
merge conflict resolved
Jul 5, 2023
34b6105
Restore config/opensearch_dashboards.yml
kajetan-nobel Jul 14, 2023
ad6bac0
Fix capabilities tsc
kajetan-nobel Jul 14, 2023
39a08e6
Merge remote-tracking branch 'origin/main' into read-only-mode-for-te…
kajetan-nobel Jul 18, 2023
8e34af2
Merge remote-tracking branch 'origin/main' into read-only-mode-for-te…
kajetan-nobel Jul 27, 2023
3f7799a
docs: read only tenant mode
kajetan-nobel Jul 28, 2023
95ed066
Merge remote-tracking branch 'origin/main' into read-only-mode-for-te…
kajetan-nobel Aug 4, 2023
83dfe9a
Merge remote-tracking branch 'origin/main' into read-only-mode-for-te…
kajetan-nobel Aug 7, 2023
edffd9d
Merge remote-tracking branch 'origin/main' into read-only-mode-for-te…
kajetan-nobel Aug 16, 2023
e71d72f
Merge remote-tracking branch 'origin/main' into read-only-mode-for-te…
kajetan-nobel Sep 5, 2023
97ffecf
feat: introduce security service in core and readonly service
kajetan-nobel Sep 13, 2023
1bcf651
Merge remote-tracking branch 'origin/main' into read-only-mode-for-te…
kajetan-nobel Sep 13, 2023
27ae5b5
fix: adds securityServiceMock
kajetan-nobel Sep 13, 2023
b02687d
feat: adds tests and default default readonly service
kajetan-nobel Sep 13, 2023
5b921d9
docs: fill up docs for read only tenant mode
kajetan-nobel Sep 13, 2023
e55caae
Update DEVELOPER_GUIDE.md
kajetan-nobel Sep 14, 2023
b64a6af
Merge branch 'main' into read-only-mode-for-tenants
kajetan-nobel Sep 15, 2023
e1b0d8e
Merge branch 'main' into read-only-mode-for-tenants
ashwin-pc Sep 20, 2023
4899570
Merge remote-tracking branch 'origin/main' into read-only-mode-for-te…
kajetan-nobel Sep 27, 2023
77d3e69
docs: update changelog
kajetan-nobel Sep 27, 2023
79da52d
Merge branch 'main' into read-only-mode-for-tenants
kajetan-nobel Oct 10, 2023
75b755a
Merge branch 'main' into read-only-mode-for-tenants
kajetan-nobel Oct 23, 2023
66100c0
feat: change to correct license headers
kajetan-nobel Oct 24, 2023
7f5ad08
Update CHANGELOG.md
kajetan-nobel Oct 30, 2023
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

### 🐛 Bug Fixes
Expand Down Expand Up @@ -807,4 +808,4 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)

### 🔩 Tests

- Update caniuse to fix failed integration tests ([#2322](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2322))
- Update caniuse to fix failed integration tests ([#2322](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2322))
6 changes: 6 additions & 0 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
80 changes: 80 additions & 0 deletions docs/capabilities/read_only_mode.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion src/core/public/application/application_service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<T>(m: Map<string, T>, capabilities: Capabilities) {
function filterAvailable<T>(m: Map<string, T>, capabilities: RecursiveReadonly<Capabilities>) {
return new Map(
[...m].filter(
([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true
Expand Down
3 changes: 3 additions & 0 deletions src/core/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -437,6 +438,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
metrics: MetricsServiceSetup;
/** {@link SavedObjectsServiceSetup} */
savedObjects: SavedObjectsServiceSetup;
/** {@link SecurityServiceSetup} */
security: SecurityServiceSetup;
/** {@link StatusServiceSetup} */
status: StatusServiceSetup;
/** {@link UiSettingsServiceSetup} */
Expand Down
2 changes: 2 additions & 0 deletions src/core/server/internal_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { InternalStatusServiceSetup } from './status';
import { AuditTrailSetup, AuditTrailStart } from './audit_trail';
import { InternalLoggingServiceSetup } from './logging';
import { CoreUsageDataStart } from './core_usage_data';
import { InternalSecurityServiceSetup } from './security/types';

/** @internal */
export interface InternalCoreSetup {
Expand All @@ -64,6 +65,7 @@ export interface InternalCoreSetup {
auditTrail: AuditTrailSetup;
logging: InternalLoggingServiceSetup;
metrics: InternalMetricsServiceSetup;
security: InternalSecurityServiceSetup;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/core/server/legacy/legacy_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { statusServiceMock } from '../status/status_service.mock';
import { auditTrailServiceMock } from '../audit_trail/audit_trail_service.mock';
import { loggingServiceMock } from '../logging/logging_service.mock';
import { metricsServiceMock } from '../metrics/metrics_service.mock';
import { securityServiceMock } from '../security/security_service.mock';

const MockOsdServer: jest.Mock<OsdServer> = OsdServer as any;

Expand Down Expand Up @@ -108,6 +109,7 @@ beforeEach(() => {
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createInternalSetupContract(),
metrics: metricsServiceMock.createInternalSetupContract(),
security: securityServiceMock.createSetupContract(),
},
plugins: { 'plugin-id': 'plugin-value' },
uiPlugins: {
Expand Down
1 change: 1 addition & 0 deletions src/core/server/legacy/legacy_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/core/server/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -157,6 +158,7 @@ function createCoreSetupMock({
getStartServices: jest
.fn<Promise<[ReturnType<typeof createCoreStartMock>, object, any]>, []>()
.mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]),
security: securityServiceMock.createSetupContract(),
};

return mock;
Expand Down Expand Up @@ -192,6 +194,7 @@ function createInternalCoreSetupMock() {
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createInternalSetupContract(),
metrics: metricsServiceMock.createInternalSetupContract(),
security: securityServiceMock.createSetupContract(),
};
return setupDeps;
}
Expand Down
1 change: 1 addition & 0 deletions src/core/server/plugins/plugin_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
},
getStartServices: () => plugin.startDependencies,
auditTrail: deps.auditTrail,
security: deps.security,
};
}

Expand Down
34 changes: 34 additions & 0 deletions src/core/server/security/readonly_service.test.ts
Original file line number Diff line number Diff line change
@@ -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 } });
});
});
22 changes: 22 additions & 0 deletions src/core/server/security/readonly_service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
Copy link
Member

Choose a reason for hiding this comment

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

What do you think of changing the output from being boolean to be an enum that has values like ReadOnly, ReadWrite, Unknown?

I think this would better describe results for inline reading especially in the controllers where its hard to 'see' what the impact of a true vs false will be.

Copy link
Contributor

@kajetan-nobel kajetan-nobel Oct 30, 2023

Choose a reason for hiding this comment

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

@peternied then it wouldn't be a ReadonlyService but TenantCapabilityService or something like that. As long as it has an impact only on capabilities I suggest keeping it as it is right now and extending that in the next PR :) Especially as long capabilities are only boolean and will not contain enum values.

Copy link
Member

Choose a reason for hiding this comment

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

Seems isReadonly method currently always returns false and the hideForReadonly method will always return the capabilites argument as-is, without merging it with hideCapabilities. Seems to me that these functions are redundant or unnecessary. Is the current isReadonly method a placeholder, intended to be overridden or expanded upon in the future? should we add a comment on them?

Copy link
Contributor

Choose a reason for hiding this comment

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

@ananzh purpose is to keep working even without security-plugin (btw. here is an extension of the implementation https://github.com/opensearch-project/security-dashboards-plugin/pull/14720). For more context please follow opensearch-project/security#2701 (comment)

return false;

Check warning on line 12 in src/core/server/security/readonly_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/security/readonly_service.ts#L12

Added line #L12 was not covered by tests
}

async hideForReadonly(
request: OpenSearchDashboardsRequest,
capabilites: Partial<Capabilities>,
hideCapabilities: Partial<Capabilities>
): Promise<Partial<Capabilities>> {
return (await this.isReadonly(request)) ? merge(capabilites, hideCapabilities) : capabilites;
}
}
18 changes: 18 additions & 0 deletions src/core/server/security/security_service.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { SecurityServiceSetup } from './types';

const createSetupContractMock = () => {
const setupContract: jest.Mocked<SecurityServiceSetup> = {
readonlyService: jest.fn(),
registerReadonlyService: jest.fn(),
};
return setupContract;
};

export const securityServiceMock = {
createSetupContract: createSetupContractMock,
};
45 changes: 45 additions & 0 deletions src/core/server/security/security_service.test.ts
Original file line number Diff line number Diff line change
@@ -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<IReadOnlyService> = {
isReadonly: jest.fn(),
hideForReadonly: jest.fn(),
};

setupContext.registerReadonlyService(readonlyServiceMock);
setupContext.readonlyService().isReadonly(request);

expect(readonlyServiceMock.isReadonly).toBeCalledTimes(1);
});
});
});
43 changes: 43 additions & 0 deletions src/core/server/security/security_service.ts
Original file line number Diff line number Diff line change
@@ -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<InternalSecurityServiceSetup> {
private logger: Logger;
private readonlyService: IReadOnlyService;

constructor(coreContext: CoreContext) {
this.logger = coreContext.logger.get('security-service');
this.readonlyService = new ReadonlyService();

Check warning on line 18 in src/core/server/security/security_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/security/security_service.ts#L17-L18

Added lines #L17 - L18 were not covered by tests
}

public setup() {
this.logger.debug('Setting up Security service');

Check warning on line 22 in src/core/server/security/security_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/security/security_service.ts#L22

Added line #L22 was not covered by tests

const securityService = this;

Check warning on line 24 in src/core/server/security/security_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/security/security_service.ts#L24

Added line #L24 was not covered by tests

return {

Check warning on line 26 in src/core/server/security/security_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/security/security_service.ts#L26

Added line #L26 was not covered by tests
registerReadonlyService(service: IReadOnlyService) {
securityService.readonlyService = service;

Check warning on line 28 in src/core/server/security/security_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/security/security_service.ts#L28

Added line #L28 was not covered by tests
},
readonlyService() {
return securityService.readonlyService;

Check warning on line 31 in src/core/server/security/security_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/security/security_service.ts#L31

Added line #L31 was not covered by tests
},
};
}

public start() {
this.logger.debug('Starting plugin');

Check warning on line 37 in src/core/server/security/security_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/security/security_service.ts#L37

Added line #L37 was not covered by tests
}

public stop() {
this.logger.debug('Stopping plugin');

Check warning on line 41 in src/core/server/security/security_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/security/security_service.ts#L41

Added line #L41 was not covered by tests
}
}
Loading
Loading