Skip to content

Commit

Permalink
[Data Source]Add data source permission wrapper and dataSourceAdmin r…
Browse files Browse the repository at this point in the history
…ole (#7959)

* Add data source permission wrapper

Signed-off-by: yubonluo <[email protected]>

* Changeset file for PR #7959 created/updated

* optimize the config schema

Signed-off-by: yubonluo <[email protected]>

* optimize the code

Signed-off-by: yubonluo <[email protected]>

* optimize the code

Signed-off-by: yubonluo <[email protected]>

* add some coments and optimize the logic

Signed-off-by: yubonluo <[email protected]>

* optimize the code

Signed-off-by: yubonluo <[email protected]>

* add unit tests

Signed-off-by: yubonluo <[email protected]>

* fix test error

Signed-off-by: yubonluo <[email protected]>

* optimize the code

Signed-off-by: yubonluo <[email protected]>

* optimize the code

Signed-off-by: yubonluo <[email protected]>

* Move some logic to workspace wrapper

Signed-off-by: yubonluo <[email protected]>

* delete useless code

Signed-off-by: yubonluo <[email protected]>

* delete useless code

Signed-off-by: yubonluo <[email protected]>

---------

Signed-off-by: yubonluo <[email protected]>
Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com>
(cherry picked from commit bc49b8c)
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
1 parent c47faf2 commit bbaf5aa
Show file tree
Hide file tree
Showing 23 changed files with 846 additions and 154 deletions.
2 changes: 2 additions & 0 deletions changelogs/fragments/7959.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- [Data source] Add data source permission wrapper and dataSourceAdmin role ([#7959](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7959))
4 changes: 4 additions & 0 deletions config/opensearch_dashboards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@
# "all": The data source can be managed by all users. Default to "all".
# data_source_management.manageableBy: "all"

# Set the backend roles in groups, whoever has the backend roles defined in this config will be regard as dataSourceAdmin.
# DataSource Admin will have the access to all the data source saved objects inside OpenSearch Dashboards by api.
# data_source_management.dataSourceAdmin.groups: ["data_source_management"]

# 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"

Expand Down
24 changes: 0 additions & 24 deletions src/core/server/saved_objects/service/saved_objects_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,30 +246,6 @@ test(`#deleteFromWorkspaces Should use update if there is existing workspaces`,
});
});

test(`#deleteFromWorkspaces Should use overwrite create if there is no existing workspaces`, async () => {
const returnValue = Symbol();
const create = jest.fn();
const mockRepository = {
get: jest.fn().mockResolvedValue({
workspaces: [],
}),
update: jest.fn().mockResolvedValue(returnValue),
create,
};
const client = new SavedObjectsClient(mockRepository);

const type = Symbol();
const id = Symbol();
const workspaces = ['id1'];
await client.deleteFromWorkspaces(type, id, workspaces);
expect(mockRepository.get).toHaveBeenCalledWith(type, id, {});
expect(mockRepository.create).toHaveBeenCalledWith(
type,
{},
{ id, overwrite: true, permissions: undefined, version: undefined }
);
});

test(`#deleteFromWorkspaces should throw error if no workspaces passed`, () => {
const mockRepository = {};
const client = new SavedObjectsClient(mockRepository);
Expand Down
27 changes: 5 additions & 22 deletions src/core/server/saved_objects/service/saved_objects_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,28 +488,11 @@ export class SavedObjectsClient {
const newWorkspaces = existingWorkspaces.filter((item) => {
return targetWorkspaces.indexOf(item) === -1;
});
if (newWorkspaces.length > 0) {
return await this.update<T>(type, id, object.attributes, {
...options,
workspaces: newWorkspaces,
version: object.version,
});
} else {
// If there is no workspaces assigned, will create object with overwrite to delete workspace property.
return await this.create(
type,
{
...object.attributes,
},
{
...options,
id,
permissions: object.permissions,
overwrite: true,
version: object.version,
}
);
}
return await this.update<T>(type, id, object.attributes, {
...options,
workspaces: newWorkspaces,
version: object.version,
});
};

/**
Expand Down
63 changes: 63 additions & 0 deletions src/core/server/utils/auth_info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { AuthStatus } from '../http/auth_state_storage';
import { httpServerMock, httpServiceMock } from '../mocks';
import { getPrincipalsFromRequest } from './auth_info';

describe('utils', () => {
const mockAuth = httpServiceMock.createAuth();
it('should return empty map when request do not have authentication', () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
mockAuth.get.mockReturnValueOnce({
status: AuthStatus.unknown,
state: {
authInfo: {
user_name: 'bar',
backend_roles: ['foo'],
},
},
});
const result = getPrincipalsFromRequest(mockRequest, mockAuth);
expect(result).toEqual({});
});

it('should return normally when request has authentication', () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
mockAuth.get.mockReturnValueOnce({
status: AuthStatus.authenticated,
state: {
authInfo: {
user_name: 'bar',
backend_roles: ['foo'],
},
},
});
const result = getPrincipalsFromRequest(mockRequest, mockAuth);
expect(result.users).toEqual(['bar']);
expect(result.groups).toEqual(['foo']);
});

it('should throw error when request is not authenticated', () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
mockAuth.get.mockReturnValueOnce({
status: AuthStatus.unauthenticated,
state: {},
});
expect(() => getPrincipalsFromRequest(mockRequest, mockAuth)).toThrow('NOT_AUTHORIZED');
});

it('should throw error when authentication status is not expected', () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
mockAuth.get.mockReturnValueOnce({
// @ts-expect-error
status: 'foo',
state: {},
});
expect(() => getPrincipalsFromRequest(mockRequest, mockAuth)).toThrow(
'UNEXPECTED_AUTHORIZATION_STATUS'
);
});
});
45 changes: 45 additions & 0 deletions src/core/server/utils/auth_info.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 { AuthStatus } from '../http/auth_state_storage';
import { OpenSearchDashboardsRequest } from '../http/router';
import { HttpAuth } from '../http/types';
import { PrincipalType, Principals } from '../saved_objects/permission_control/acl';

export interface AuthInfo {
backend_roles?: string[];
user_name?: string;
}

export const getPrincipalsFromRequest = (
request: OpenSearchDashboardsRequest,
auth?: HttpAuth
): Principals => {
const payload: Principals = {};
const authInfoResp = auth?.get(request);
if (authInfoResp?.status === AuthStatus.unknown) {
/**
* Login user have access to all the workspaces when no authentication is presented.
*/
return payload;
}

if (authInfoResp?.status === AuthStatus.authenticated) {
const authState = authInfoResp?.state as { authInfo: AuthInfo } | null;
if (authState?.authInfo?.backend_roles) {
payload[PrincipalType.Groups] = authState.authInfo.backend_roles;
}
if (authState?.authInfo?.user_name) {
payload[PrincipalType.Users] = [authState.authInfo.user_name];
}
return payload;
}

if (authInfoResp?.status === AuthStatus.unauthenticated) {
throw new Error('NOT_AUTHORIZED');
}

throw new Error('UNEXPECTED_AUTHORIZATION_STATUS');
};
1 change: 1 addition & 0 deletions src/core/server/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ export * from './crypto';
export * from './from_root';
export * from './package_json';
export * from './streams';
export { getPrincipalsFromRequest } from './auth_info';
export { getWorkspaceIdFromUrl, cleanWorkspaceId } from '../../utils';
export { updateWorkspaceState, getWorkspaceState } from './workspace';
2 changes: 2 additions & 0 deletions src/core/server/utils/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ describe('updateWorkspaceState', () => {
updateWorkspaceState(requestMock, {
requestWorkspaceId: 'foo',
isDashboardAdmin: true,
isDataSourceAdmin: true,
});
expect(getWorkspaceState(requestMock)).toEqual({
requestWorkspaceId: 'foo',
isDashboardAdmin: true,
isDataSourceAdmin: true,
});
});
});
6 changes: 5 additions & 1 deletion src/core/server/utils/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { OpenSearchDashboardsRequest, ensureRawRequest } from '../http/router';
export interface WorkspaceState {
requestWorkspaceId?: string;
isDashboardAdmin?: boolean;
isDataSourceAdmin?: boolean;
}

/**
Expand All @@ -29,10 +30,13 @@ export const updateWorkspaceState = (
};
};

// TODO: Move isDataSourceAdmin and isDashboardAdmin out of WorkspaceState and this change is planned for version 2.18
export const getWorkspaceState = (request: OpenSearchDashboardsRequest): WorkspaceState => {
const { requestWorkspaceId, isDashboardAdmin } = ensureRawRequest(request).app as WorkspaceState;
const { requestWorkspaceId, isDashboardAdmin, isDataSourceAdmin } = ensureRawRequest(request)
.app as WorkspaceState;
return {
requestWorkspaceId,
isDashboardAdmin,
isDataSourceAdmin,
};
};
3 changes: 3 additions & 0 deletions src/plugins/data_source_management/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ export const PLUGIN_ID = 'dataSourceManagement';
export const PLUGIN_NAME = 'Data sources';
export const DEFAULT_DATA_SOURCE_UI_SETTINGS_ID = 'defaultDataSource';
export * from './types';
export const DATA_SOURCE_PERMISSION_CLIENT_WRAPPER_ID = 'data-source-permission';
// Run data source permission wrapper behind all other wrapper.
export const ORDER_FOR_DATA_SOURCE_PERMISSION_WRAPPER = 50;
5 changes: 5 additions & 0 deletions src/plugins/data_source_management/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export const configSchema = schema.object({
[schema.literal('all'), schema.literal('dashboard_admin'), schema.literal('none')],
{ defaultValue: 'all' }
),
dataSourceAdmin: schema.object({
groups: schema.arrayOf(schema.string(), {
defaultValue: [],
}),
}),
});

export type ConfigSchema = TypeOf<typeof configSchema>;
44 changes: 42 additions & 2 deletions src/plugins/data_source_management/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,17 @@ import { DataSourceManagementPluginSetup, DataSourceManagementPluginStart } from
import { OpenSearchDataSourceManagementPlugin } from './adaptors/opensearch_data_source_management_plugin';
import { PPLPlugin } from './adaptors/ppl_plugin';
import { ConfigSchema } from '../config';
import { getWorkspaceState } from '../../../../src/core/server/utils';
import { ManageableBy } from '../common';
import {
getPrincipalsFromRequest,
getWorkspaceState,
updateWorkspaceState,
} from '../../../../src/core/server/utils';
import {
DATA_SOURCE_PERMISSION_CLIENT_WRAPPER_ID,
ManageableBy,
ORDER_FOR_DATA_SOURCE_PERMISSION_WRAPPER,
} from '../common';
import { DataSourcePermissionClientWrapper } from './saved_objects/data_source_premission_client_wrapper';

export interface DataSourceManagementPluginDependencies {
dataSource: DataSourcePluginSetup;
Expand All @@ -33,6 +42,35 @@ export class DataSourceManagementPlugin
private readonly config$: Observable<ConfigSchema>;
private readonly logger: Logger;

private setupDataSourcePermission(core: CoreSetup, config: ConfigSchema) {
core.http.registerOnPostAuth(async (request, response, toolkit) => {
let groups: string[];
const [coreStart] = await core.getStartServices();

try {
({ groups = [] } = getPrincipalsFromRequest(request, coreStart.http.auth));
} catch (e) {
return toolkit.next();
}

const configGroups = config.dataSourceAdmin.groups;
const isDataSourceAdmin = configGroups.some((configGroup) => groups.includes(configGroup));
updateWorkspaceState(request, {
isDataSourceAdmin,
});
return toolkit.next();
});

const dataSourcePermissionWrapper = new DataSourcePermissionClientWrapper(config.manageableBy);

// Add data source permission client wrapper factory
core.savedObjects.addClientWrapper(
ORDER_FOR_DATA_SOURCE_PERMISSION_WRAPPER,
DATA_SOURCE_PERMISSION_CLIENT_WRAPPER_ID,
dataSourcePermissionWrapper.wrapperFactory
);
}

constructor(initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
this.config$ = initializerContext.config.create<ConfigSchema>();
Expand Down Expand Up @@ -82,6 +120,8 @@ export class DataSourceManagementPlugin
if (dataSourceEnabled) {
dataSource.registerCustomApiSchema(PPLPlugin);
dataSource.registerCustomApiSchema(OpenSearchDataSourceManagementPlugin);

this.setupDataSourcePermission(core, config);
}
// @ts-ignore
core.http.registerRouteHandlerContext(
Expand Down
Loading

0 comments on commit bbaf5aa

Please sign in to comment.